Compare commits

..

2 Commits

Author SHA1 Message Date
henrygd
dae8181f47 fix: improve agent installer error handling (#1971, #1972)
- Enable set -eu and handle expected failures explicitly
- Validate platform and service manager before making changes
- Fix argument parsing, version fallback, cron setup, and prompt EOF
- Verify downloads before stopping the existing agent
- Add atomic binary replacement, rollback, and cleanup
- Add regression tests for installation failure paths
2026-09-05 13:02:15 -04:00
Elan Ruusamäe
b8fb5d2367 Fix: Enable immediate exit on errors in install-agent.sh (#1972)
Add error handling to ensure script exits on errors.
2026-09-05 12:05:45 -04:00
128 changed files with 2786 additions and 7217 deletions

View File

@@ -2,8 +2,6 @@
## Reporting a Vulnerability ## Reporting a Vulnerability
**PLEASE ONLY USE SECURITY ADVISORIES FOR REAL HIGH SEVERITY VULNERABILITIES.** If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If you find a vulnerability in the latest version, and it is not high severity, open an issue instead of an advisory. If it's low severity (use best judgement) you may open an issue instead of an advisory.
I am overwhelmed with advisories, often erroneous, which are clearly found and written by AI. I don't have the capacity to review all of them.

View File

@@ -48,7 +48,7 @@ type Agent struct {
keys []gossh.PublicKey // SSH public keys keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services systemdManager *systemdManager // Manages systemd services
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data zfsManager *ZfsManager // Manages ZFS pool and dataset data
} }
// NewAgent creates a new agent with the given data directory for persisting data. // NewAgent creates a new agent with the given data directory for persisting data.
@@ -122,12 +122,12 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize handler registry // initialize handler registry
agent.handlerRegistry = NewHandlerRegistry() agent.handlerRegistry = NewHandlerRegistry()
agent.storagePoolManager = newStoragePoolManager() agent.zfsManager = newZfsManager()
// Retain ZFS_INTERVAL for the shared storage pool detail refresh interval. // ZFS_INTERVAL env var to update ZFS detail data at this interval
if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists { if zfsIntervalEnv, exists := utils.GetEnv("ZFS_INTERVAL"); exists {
if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 { if duration, err := time.ParseDuration(zfsIntervalEnv); err == nil && duration > 0 {
agent.storagePoolManager.detailInterval = duration agent.zfsManager.detailInterval = duration
agent.systemDetails.ZfsInterval = duration agent.systemDetails.ZfsInterval = duration
slog.Info("ZFS_INTERVAL", "duration", duration) slog.Info("ZFS_INTERVAL", "duration", duration)
} else { } else {

View File

@@ -1,26 +0,0 @@
// Package btrfs reads btrfs filesystem state from sysfs.
package btrfs
// Filesystem is a mounted btrfs filesystem read from /sys/fs/btrfs/<uuid>.
type Filesystem struct {
UUID string // stable filesystem UUID from sysfs
MountID string // kernel filesystem identity for matching monitored mounts
IODevice string // sole member block-device name, empty for multi-device/unknown pools
Name string // label, else first mountpoint, else UUID
Size uint64 // effective usable capacity, or raw member capacity when Raw
Raw bool // capacity and usage are physical bytes, unsuitable for disk alerts
Alloc uint64 // raw bytes allocated to data, metadata and system chunks
Health string // ONLINE, or DEGRADED when a device is missing
NRead uint64 // cumulative bytes read across member devices
NWrite uint64 // cumulative bytes written across member devices
Devices []Device
}
// Device is one member device (devinfo/<devid>) with its error counters.
type Device struct {
Name string // "devid N"; sysfs does not expose the block device path
State string // ONLINE or MISSING
ReadErrs uint64
WriteErrs uint64
CorruptionErrs uint64
}

View File

@@ -1,285 +0,0 @@
//go:build linux
package btrfs
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"unsafe"
"github.com/henrygd/beszel/agent/utils"
"golang.org/x/sys/unix"
)
var (
sysfsPath = "/sys/fs/btrfs"
mountsPath = "/proc/self/mounts"
mountinfoPath = "/proc/self/mountinfo"
mountUUID = MountID
deviceSize = ioctlDeviceSize
filesystemUsage = statfsUsage
)
// Filesystems returns all mounted btrfs filesystems, or nil when there are none.
func Filesystems() ([]Filesystem, error) {
entries, err := os.ReadDir(sysfsPath)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
mounts := mountpointsByDevice()
var filesystems []Filesystem
for _, entry := range entries {
if !entry.IsDir() || entry.Name() == "features" {
continue
}
fs, err := readFilesystem(filepath.Join(sysfsPath, entry.Name()), mounts)
if err != nil {
return nil, fmt.Errorf("btrfs %s: %w", entry.Name(), err)
}
filesystems = append(filesystems, fs)
}
return filesystems, nil
}
func readFilesystem(dir string, mounts map[string]string) (Filesystem, error) {
fs := Filesystem{UUID: filepath.Base(dir), Name: utils.ReadStringFile(filepath.Join(dir, "label")), Health: "UNKNOWN"}
for _, kind := range []string{"data", "metadata", "system"} {
if value, ok := utils.ReadUintFile(filepath.Join(dir, "allocation", kind, "disk_used")); ok {
fs.Alloc += value
}
}
// devices/<name> links to the block device's sysfs directory.
devices, err := os.ReadDir(filepath.Join(dir, "devices"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fs, err
}
mountpoint := mounts["uuid:"+fs.UUID]
if fs.Name == "" {
fs.Name = mountpoint
}
var backingSize uint64
for _, dev := range devices {
if mountpoint == "" {
mountpoint = mounts[dev.Name()]
}
if fs.Name == "" {
fs.Name = mountpoint
}
devDir := filepath.Join(dir, "devices", dev.Name())
if size, ok := utils.ReadUintFile(filepath.Join(devDir, "size")); ok {
backingSize += size * 512
}
if stat := strings.Fields(utils.ReadStringFile(filepath.Join(devDir, "stat"))); len(stat) >= 7 {
fs.NRead += parseUint(stat[2]) * 512
fs.NWrite += parseUint(stat[6]) * 512
}
}
devids, err := os.ReadDir(filepath.Join(dir, "devinfo"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fs, err
}
capacityAvailable := len(devids) > 0
healthKnown := len(devids) > 0
for _, devid := range devids {
devDir := filepath.Join(dir, "devinfo", devid.Name())
// Replacement targets do not add filesystem capacity.
replaceTarget, _ := utils.ReadUintFile(filepath.Join(devDir, "replace_target"))
if replaceTarget != 1 {
devid, err := strconv.ParseUint(devid.Name(), 10, 64)
if err != nil {
return fs, err
}
size, err := deviceSize(mountpoint, devid)
if err != nil {
capacityAvailable = false
}
fs.Size += size
}
dev := Device{Name: "devid " + devid.Name(), State: "ONLINE"}
missing := utils.ReadStringFile(filepath.Join(devDir, "missing"))
if missing != "0" && missing != "1" {
healthKnown = false
dev.State = "UNKNOWN"
}
if missing == "1" {
dev.State = "MISSING"
fs.Health = "DEGRADED"
}
for line := range strings.Lines(utils.ReadStringFile(filepath.Join(devDir, "error_stats"))) {
if fields := strings.Fields(line); len(fields) == 2 {
switch fields[0] {
case "read_errs":
dev.ReadErrs = parseUint(fields[1])
case "write_errs":
dev.WriteErrs = parseUint(fields[1])
case "corruption_errs":
dev.CorruptionErrs = parseUint(fields[1])
}
}
}
fs.Devices = append(fs.Devices, dev)
}
// Use one capacity source for the whole filesystem: device IDs cannot be
// reliably matched to block-device names in sysfs. A partial ioctl result
// must not be added to the complete backing-device total.
if !capacityAvailable {
fs.Size = backingSize
}
if fs.Health != "DEGRADED" && healthKnown {
fs.Health = "ONLINE"
}
fs.MountID = mountUUID(mountpoint)
if len(devices) == 1 && len(devids) == 1 && fs.Health == "ONLINE" {
fs.IODevice = devices[0].Name()
}
fs.Raw = true
if used, available, err := filesystemUsage(mountpoint); err == nil {
// Effective capacity excludes reserved/unavailable space, so Size-Alloc
// is available to applications and the usage ratio matches df.
fs.Size, fs.Alloc, fs.Raw = used+available, used, false
}
if fs.Name == "" {
fs.Name = filepath.Base(dir)
}
return fs, nil
}
// mountpointsByDevice prefers UUID matches from mountinfo and retains source
// device names as a fallback for environments where FS_INFO is unavailable.
func mountpointsByDevice() map[string]string {
mounts := mountpointsByUUID(utils.ReadStringFile(mountinfoPath), mountUUID)
for line := range strings.Lines(utils.ReadStringFile(mountsPath)) {
fields := strings.Fields(line)
if len(fields) < 3 || fields[2] != "btrfs" {
continue
}
device := fields[0]
if resolved, err := filepath.EvalSymlinks(device); err == nil {
device = resolved
}
if _, seen := mounts[filepath.Base(device)]; !seen {
mounts[filepath.Base(device)] = unescapeMountPath(fields[1])
}
}
return mounts
}
func parseUint(s string) uint64 {
n, _ := strconv.ParseUint(s, 10, 64)
return n
}
// ioctlDeviceSize reads Btrfs's recorded device size, which can be smaller
// than the block device after a filesystem resize. BTRFS_IOC_DEV_INFO is
// _IOWR(0x94, 30, struct btrfs_ioctl_dev_info_args), a 4096-byte ABI structure.
func ioctlDeviceSize(mountpoint string, devid uint64) (uint64, error) {
if mountpoint == "" {
return 0, errors.New("no accessible mountpoint")
}
f, err := os.Open(mountpoint)
if err != nil {
return 0, err
}
defer f.Close()
args := struct {
Devid uint64
UUID [16]byte
BytesUsed uint64
TotalBytes uint64
Reserved [4096 - 40]byte
}{Devid: devid}
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), 0xd000941e, uintptr(unsafe.Pointer(&args)))
if errno != 0 {
return 0, errno
}
return args.TotalBytes, nil
}
// The filesystem magic is unsigned even when Statfs_t.Type is int32.
func isBtrfs(stat *unix.Statfs_t) bool {
return uint32(stat.Type) == unix.BTRFS_SUPER_MAGIC
}
func statfsUsage(path string) (used, available uint64, err error) {
if path == "" {
return 0, 0, errors.New("no accessible mountpoint")
}
var stat unix.Statfs_t
if err = unix.Statfs(path, &stat); err != nil {
return
}
if !isBtrfs(&stat) {
return 0, 0, errors.New("mountpoint is not Btrfs")
}
blockSize := uint64(stat.Bsize)
return (stat.Blocks - min(stat.Blocks, stat.Bfree)) * blockSize, min(stat.Blocks, stat.Bavail) * blockSize, nil
}
// MountID returns the filesystem UUID via BTRFS_IOC_FS_INFO. Unlike statfs
// f_fsid, this identity is shared by all subvolumes and bind mounts.
func MountID(path string) string {
if path == "" {
return ""
}
var stat unix.Statfs_t
if unix.Statfs(path, &stat) != nil || !isBtrfs(&stat) {
return ""
}
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
args := struct {
MaxID uint64
NumDevices uint64
FSID [16]byte
Reserved [992]byte
}{}
// _IOR(0x94, 31, 1024). Reuse the platform's read-direction bits;
// MIPS/PowerPC use a different encoding than asm-generic.
request := uintptr(unix.FS_IOC_GETFLAGS&0xe0000000) | 0x0400941f
_, _, errno := unix.Syscall(unix.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(&args)))
if errno != 0 {
return ""
}
id := args.FSID
return fmt.Sprintf("%x-%x-%x-%x-%x", id[:4], id[4:6], id[6:8], id[8:10], id[10:])
}
// Btrfs mountinfo device numbers can be virtual (0:N), so query the UUID
// through the mount instead of comparing those numbers with sysfs block devs.
// Retry another path when a bind mount is inaccessible. Once resolved, reuse
// the result for that mount device to avoid opening every Docker bind mount.
func mountpointsByUUID(mountinfo string, identify func(string) string) map[string]string {
mounts := make(map[string]string)
resolved := make(map[string]bool)
for line := range strings.Lines(mountinfo) {
before, after, ok := strings.Cut(line, " - ")
fields, fs := strings.Fields(before), strings.Fields(after)
if !ok || len(fields) < 6 || len(fs) < 3 || fs[0] != "btrfs" || resolved[fields[2]] {
continue
}
path := unescapeMountPath(fields[4])
uuid := identify(path)
if uuid == "" {
continue
}
resolved[fields[2]] = true
if mounts["uuid:"+uuid] == "" {
mounts["uuid:"+uuid] = path
}
}
return mounts
}
func unescapeMountPath(path string) string {
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(path)
}

View File

@@ -1,274 +0,0 @@
//go:build testing && linux
package btrfs
import (
"os"
"path/filepath"
"strconv"
"testing"
"github.com/henrygd/beszel/agent/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
func TestFilesystems(t *testing.T) {
root := t.TempDir()
oldSysfs, oldMounts := sysfsPath, mountsPath
sysfsPath, mountsPath = root, filepath.Join(root, "mounts")
t.Cleanup(func() { sysfsPath, mountsPath = oldSysfs, oldMounts })
fsDir := filepath.Join(root, "1b2c3d4e-0000-0000-0000-000000000000")
write := func(rel, content string) {
path := filepath.Join(fsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
require.NoError(t, os.MkdirAll(filepath.Join(root, "features"), 0o755))
oldUsage := filesystemUsage
filesystemUsage = func(string) (uint64, uint64, error) { return 0, 0, os.ErrNotExist }
t.Cleanup(func() { filesystemUsage = oldUsage })
oldDeviceSize := deviceSize
t.Cleanup(func() { deviceSize = oldDeviceSize })
deviceSize = func(_ string, devid uint64) (uint64, error) {
value, _ := utils.ReadUintFile(filepath.Join(fsDir, "recorded-size", strconv.FormatUint(devid, 10)))
return value, nil
}
// Recorded member capacities differ from the unchanged backing devices.
write("recorded-size/1", "256000\n")
write("recorded-size/2", "128000\n")
write("label", "tank\n")
write("allocation/data/disk_used", "4096\n")
write("allocation/metadata/disk_used", "2048\n")
write("allocation/system/disk_used", "1024\n")
write("devices/sda/size", "1000\n")
write("devices/sda/stat", "10 0 200 0 20 0 400 0 0 0 0\n")
write("devices/sdb/size", "1000\n")
write("devices/sdb/stat", "10 0 100 0 20 0 100 0 0 0 0\n")
write("devinfo/1/missing", "0\n")
write("devinfo/1/error_stats", "write_errs 1\nread_errs 2\nflush_errs 0\ncorruption_errs 3\ngeneration_errs 0\n")
write("devinfo/2/missing", "1\n")
filesystems, err := Filesystems()
require.NoError(t, err)
require.Len(t, filesystems, 1)
assert.Equal(t, Filesystem{
UUID: "1b2c3d4e-0000-0000-0000-000000000000", Raw: true, Name: "tank", Size: 384000, Alloc: 7168, Health: "DEGRADED", NRead: 153600, NWrite: 256000,
Devices: []Device{
{Name: "devid 1", State: "ONLINE", ReadErrs: 2, WriteErrs: 1, CorruptionErrs: 3},
{Name: "devid 2", State: "MISSING"},
},
}, filesystems[0])
// Unlabeled filesystems fall back to the first mountpoint, then the UUID.
write("label", "\n")
require.NoError(t, os.WriteFile(mountsPath, []byte(
"/dev/sdz1 /other btrfs rw 0 0\n/dev/sdb /mnt/storage btrfs rw 0 0\n/dev/sdb /mnt/storage/sub btrfs rw,subvol=/sub 0 0\n",
), 0o644))
filesystems, err = Filesystems()
require.NoError(t, err)
assert.Equal(t, "/mnt/storage", filesystems[0].Name)
require.NoError(t, os.Remove(mountsPath))
filesystems, err = Filesystems()
require.NoError(t, err)
assert.Equal(t, "1b2c3d4e-0000-0000-0000-000000000000", filesystems[0].Name)
write("devinfo/3/replace_target", "1\n")
write("recorded-size/3", "512000\n")
filesystems, err = Filesystems()
require.NoError(t, err)
assert.Equal(t, uint64(384000), filesystems[0].Size, "replacement target must not inflate capacity")
deviceSize = func(string, uint64) (uint64, error) { return 0, os.ErrPermission }
filesystems, err = Filesystems()
require.NoError(t, err)
require.Len(t, filesystems, 1)
assert.Equal(t, uint64(1024000), filesystems[0].Size)
assert.Equal(t, "DEGRADED", filesystems[0].Health)
assert.Equal(t, uint64(153600), filesystems[0].NRead)
// A partial ioctl result must not be mixed with the backing-device total.
deviceSize = func(_ string, devid uint64) (uint64, error) {
if devid == 2 {
return 0, os.ErrPermission
}
return 256000, nil
}
filesystems, err = Filesystems()
require.NoError(t, err)
assert.Equal(t, uint64(1024000), filesystems[0].Size)
// With no mount visible (e.g. Docker), the real lookup falls back too.
deviceSize = ioctlDeviceSize
filesystems, err = Filesystems()
require.NoError(t, err)
require.Len(t, filesystems, 1)
assert.Equal(t, uint64(1024000), filesystems[0].Size)
filesystemUsage = func(string) (uint64, uint64, error) { return 100, 900, nil }
filesystems, err = Filesystems()
require.NoError(t, err)
assert.Equal(t, uint64(1000), filesystems[0].Size)
assert.Equal(t, uint64(100), filesystems[0].Alloc)
assert.False(t, filesystems[0].Raw)
}
func TestFilesystemsNoBtrfs(t *testing.T) {
oldPath := sysfsPath
sysfsPath = filepath.Join(t.TempDir(), "missing")
t.Cleanup(func() { sysfsPath = oldPath })
filesystems, err := Filesystems()
require.NoError(t, err)
assert.Nil(t, filesystems)
}
func TestIoctlDeviceSizeFailure(t *testing.T) {
_, err := ioctlDeviceSize("", 1)
require.Error(t, err)
_, err = ioctlDeviceSize(t.TempDir(), 1)
require.Error(t, err)
assert.ErrorIs(t, err, unix.ENOTTY)
}
func TestMountpointsDecodeEscapes(t *testing.T) {
oldMounts := mountsPath
mountsPath = filepath.Join(t.TempDir(), "mounts")
t.Cleanup(func() { mountsPath = oldMounts })
require.NoError(t, os.WriteFile(mountsPath, []byte("/dev/test-btrfs /mnt/my\\040data btrfs rw 0 0\n"), 0o644))
assert.Equal(t, "/mnt/my data", mountpointsByDevice()["test-btrfs"])
}
func TestFilesystemWithoutDevinfo(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "devices", "sda"), 0755))
require.NoError(t, os.WriteFile(filepath.Join(root, "devices", "sda", "size"), []byte("1000"), 0644))
fs, err := readFilesystem(root, nil)
require.NoError(t, err)
assert.Equal(t, uint64(512000), fs.Size)
assert.True(t, fs.Raw)
assert.Equal(t, "UNKNOWN", fs.Health)
assert.Empty(t, fs.Devices)
require.NoError(t, os.MkdirAll(filepath.Join(root, "devinfo", "1"), 0755))
fs, err = readFilesystem(root, nil)
require.NoError(t, err)
assert.Equal(t, "UNKNOWN", fs.Health)
require.Len(t, fs.Devices, 1)
assert.Equal(t, "UNKNOWN", fs.Devices[0].State)
// Some older interfaces lack the devices directory too.
fs, err = readFilesystem(t.TempDir(), nil)
require.NoError(t, err)
assert.Equal(t, "UNKNOWN", fs.Health)
}
func TestLocalBtrfsUsage(t *testing.T) {
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
if path == "" {
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for read-only live validation")
}
used, available, err := statfsUsage(path)
require.NoError(t, err)
filesystems, err := Filesystems()
require.NoError(t, err)
for _, fs := range filesystems {
if !fs.Raw && fs.Alloc == used && fs.Size == used+available {
t.Logf("pool=%s used=%d available=%d effective_capacity=%d", fs.Name, used, available, fs.Size)
return
}
}
t.Fatal("collector did not report the mounted filesystem's usable capacity")
}
func TestMountID(t *testing.T) {
assert.Empty(t, MountID(""))
assert.Empty(t, MountID(filepath.Join(t.TempDir(), "missing")))
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
if path == "" {
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live identity validation")
}
id := MountID(path)
require.NotEmpty(t, id)
assert.Equal(t, id, MountID(filepath.Join(path, ".")))
}
func TestMountinfoUUIDLookup(t *testing.T) {
info := `1 0 0:40 /@ /inaccessible ro shared:1 - btrfs /dev/mapper/unavailable rw
2 0 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/unavailable rw
3 0 0:40 /@/docker/hostname /etc/hostname ro - btrfs /dev/mapper/unavailable rw
4 0 0:41 /subvol /extra-filesystems/my\040disk ro master:2 - btrfs /dev/missing rw
5 0 0:42 / /ext4 ro - ext4 /dev/mapper/unavailable rw
malformed
6 0 0:43 / /bad ro - btrfs
`
var calls []string
mounts := mountpointsByUUID(info, func(path string) string {
calls = append(calls, path)
switch path {
case "/etc/hosts":
return "root-uuid"
case "/extra-filesystems/my disk":
return "extra-uuid"
}
return ""
})
assert.Equal(t, map[string]string{"uuid:root-uuid": "/etc/hosts", "uuid:extra-uuid": "/extra-filesystems/my disk"}, mounts)
assert.Equal(t, []string{"/inaccessible", "/etc/hosts", "/extra-filesystems/my disk"}, calls)
}
func TestDockerFilesystemWithoutDeviceNodes(t *testing.T) {
root := t.TempDir()
oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage := sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage
t.Cleanup(func() {
sysfsPath, mountsPath, mountinfoPath, mountUUID, filesystemUsage = oldSysfs, oldMounts, oldInfo, oldUUID, oldUsage
})
sysfsPath = filepath.Join(root, "sysfs")
mountsPath = filepath.Join(root, "missing-mounts")
mountinfoPath = filepath.Join(root, "mountinfo")
uuid := "11111111-1111-4111-8111-111111111111"
dir := filepath.Join(sysfsPath, uuid)
for path, content := range map[string]string{"devices/dm-0/size": "1000", "devinfo/1/missing": "0"} {
target := filepath.Join(dir, path)
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0755))
require.NoError(t, os.WriteFile(target, []byte(content), 0644))
}
require.NoError(t, os.WriteFile(mountinfoPath, []byte("2 1 0:40 /@/docker/hosts /etc/hosts ro - btrfs /dev/mapper/not-in-container rw\n"), 0644))
mountUUID = func(path string) string {
if path == "/etc/hosts" {
return uuid
}
return ""
}
filesystemUsage = func(path string) (uint64, uint64, error) { require.Equal(t, "/etc/hosts", path); return 100, 900, nil }
fs, err := Filesystems()
require.NoError(t, err)
require.Len(t, fs, 1)
assert.Equal(t, uuid, fs[0].MountID)
assert.Equal(t, "dm-0", fs[0].IODevice)
assert.False(t, fs[0].Raw)
assert.Equal(t, uint64(1000), fs[0].Size)
}
func TestLivePoolMountIdentity(t *testing.T) {
path := os.Getenv("BESZEL_TEST_BTRFS_MOUNT")
if path == "" {
t.Skip("set BESZEL_TEST_BTRFS_MOUNT for live validation")
}
id := MountID(path)
require.NotEmpty(t, id)
pools, err := Filesystems()
require.NoError(t, err)
for _, pool := range pools {
if pool.UUID != id {
continue
}
assert.Equal(t, id, pool.MountID)
assert.False(t, pool.Raw)
t.Logf("uuid=%s mount_identity=%s io_device=%s raw=%v", pool.UUID, pool.MountID, pool.IODevice, pool.Raw)
return
}
t.Fatal("mounted Btrfs filesystem was not discovered")
}

View File

@@ -1,11 +0,0 @@
//go:build !linux
package btrfs
import "errors"
func Filesystems() ([]Filesystem, error) {
return nil, errors.ErrUnsupported
}
func MountID(string) string { return "" }

View File

@@ -25,16 +25,9 @@ import (
) )
const ( const (
// Keep the connection alive long enough for a slow collection cycle to wsDeadline = 70 * time.Second
// finish before the hub considers the agent disconnected.
wsDeadline = 120 * time.Second
) )
// errNoHubURL is returned when HUB_URL is unset. This is not a failure
// condition: an agent configured with only a public key runs in SSH-only mode,
// where the hub dials the agent and no outbound WebSocket client is expected.
var errNoHubURL = errors.New("HUB_URL environment variable not set")
type caCertFileError struct { type caCertFileError struct {
err error err error
} }
@@ -68,7 +61,7 @@ type WebSocketClient struct {
func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) { func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
hubURLStr, exists := utils.GetEnv("HUB_URL") hubURLStr, exists := utils.GetEnv("HUB_URL")
if !exists { if !exists {
return nil, errNoHubURL return nil, errors.New("HUB_URL environment variable not set")
} }
client = &WebSocketClient{} client = &WebSocketClient{}

View File

@@ -32,28 +32,6 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// TestNewWebSocketClientNoHubURL verifies that an unset HUB_URL returns the
// errNoHubURL sentinel rather than an opaque error. Callers rely on this to
// distinguish SSH-only mode -- a supported configuration in which the hub dials
// the agent -- from an actual misconfiguration.
func TestNewWebSocketClientNoHubURL(t *testing.T) {
agent := createTestAgent(t)
// t.Setenv registers restoration of the original value; unset afterwards so
// GetEnv's LookupEnv reports the variable as absent rather than empty.
t.Setenv("BESZEL_AGENT_HUB_URL", "")
os.Unsetenv("BESZEL_AGENT_HUB_URL")
t.Setenv("HUB_URL", "")
os.Unsetenv("HUB_URL")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
client, err := newWebSocketClient(agent)
require.Error(t, err)
assert.Nil(t, client)
assert.ErrorIs(t, err, errNoHubURL)
}
// TestNewWebSocketClient tests WebSocket client creation // TestNewWebSocketClient tests WebSocket client creation
func TestNewWebSocketClient(t *testing.T) { func TestNewWebSocketClient(t *testing.T) {
agent := createTestAgent(t) agent := createTestAgent(t)
@@ -722,11 +700,3 @@ func TestGetToken(t *testing.T) {
assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content") assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content")
}) })
} }
func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
const minimumDeadline = 120 * time.Second
if wsDeadline < minimumDeadline {
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
}
}

View File

@@ -91,15 +91,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
if errors.As(err, &caCertErr) { if errors.As(err, &caCertErr) {
return err return err
} }
disableSSH, _ := utils.GetEnv("DISABLE_SSH") slog.Warn("Error creating WebSocket client", "err", err)
if errors.Is(err, errNoHubURL) && disableSSH != "true" {
// SSH-only mode: the hub dials the agent, so there is nothing to warn
// about. With SSH also disabled there is no connection method at all,
// so that case still warns.
slog.Debug("WebSocket client not configured", "err", err)
} else {
slog.Warn("Error creating WebSocket client", "err", err)
}
} }
c.wsClient = wsClient c.wsClient = wsClient

View File

@@ -18,11 +18,11 @@ import (
// fsRegistrationContext holds the shared lookup state needed to resolve a // fsRegistrationContext holds the shared lookup state needed to resolve a
// filesystem into the tracked fsStats key and metadata. // filesystem into the tracked fsStats key and metadata.
type fsRegistrationContext struct { type fsRegistrationContext struct {
filesystem string // device part of optional FILESYSTEM env var filesystem string // device part of optional FILESYSTEM env var
filesystemName string // optional custom name from FILESYSTEM=device__name filesystemName string // optional custom name from FILESYSTEM=device__name
isWindows bool isWindows bool
efPath string // path to extra filesystems (default "/extra-filesystems") efPath string // path to extra filesystems (default "/extra-filesystems")
diskIoCounters map[string]disk.IOCountersStat diskIoCounters map[string]disk.IOCountersStat
} }
// diskDiscovery groups the transient state for a single initializeDiskInfo run so // diskDiscovery groups the transient state for a single initializeDiskInfo run so
@@ -325,11 +325,11 @@ func (a *Agent) initializeDiskInfo() {
} }
slog.Debug("Disk I/O", "diskstats", diskIoCounters) slog.Debug("Disk I/O", "diskstats", diskIoCounters)
ctx := fsRegistrationContext{ ctx := fsRegistrationContext{
filesystem: filesystem, filesystem: filesystem,
filesystemName: filesystemName, filesystemName: filesystemName,
isWindows: isWindows, isWindows: isWindows,
diskIoCounters: diskIoCounters, diskIoCounters: diskIoCounters,
efPath: "/extra-filesystems", efPath: "/extra-filesystems",
} }
// Get the appropriate root mount point for this system // Get the appropriate root mount point for this system
@@ -540,8 +540,8 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
// ZFS datasets have no /proc/diskstats entry, so they are excluded from // ZFS datasets have no /proc/diskstats entry, so they are excluded from
// I/O tracking instead of warning about a missing device (#1541). // I/O tracking instead of warning about a missing device (#1541).
var zfsMountpoints map[string]bool var zfsMountpoints map[string]bool
if a.storagePoolManager != nil { if a.zfsManager != nil {
zfsMountpoints = a.storagePoolManager.ZfsMountpoints() zfsMountpoints = a.zfsManager.ZfsMountpoints()
} }
for device, stats := range a.fsStats { for device, stats := range a.fsStats {
if zfsMountpoints[stats.Mountpoint] { if zfsMountpoints[stats.Mountpoint] {
@@ -574,8 +574,8 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
// ZFS dataset mountpoints use `zfs list` values because statfs(2) reports // ZFS dataset mountpoints use `zfs list` values because statfs(2) reports
// dataset-level usage that excludes child datasets (#1541). // dataset-level usage that excludes child datasets (#1541).
var zfsUsage map[string]zfsDatasetUsage var zfsUsage map[string]zfsDatasetUsage
if a.storagePoolManager != nil { if a.zfsManager != nil {
zfsUsage = a.storagePoolManager.DatasetUsage() zfsUsage = a.zfsManager.DatasetUsage()
} }
// disk usage // disk usage

View File

@@ -4,7 +4,6 @@ package agent
import ( import (
"testing" "testing"
"time"
"github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
@@ -17,8 +16,8 @@ import (
// is a ZFS dataset reports `zfs list` usage (which includes child datasets) // is a ZFS dataset reports `zfs list` usage (which includes child datasets)
// instead of the dataset-scoped statfs values (#1541). // instead of the dataset-scoped statfs values (#1541).
func TestUpdateDiskUsageZfsMountpoint(t *testing.T) { func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}} zm := &ZfsManager{}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{ return []zfs.Dataset{
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"}, {Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
}, nil }, nil
@@ -27,7 +26,7 @@ func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
fsStats: map[string]*system.FsStats{ fsStats: map[string]*system.FsStats{
"tank": {Root: false, Mountpoint: "/tank"}, "tank": {Root: false, Mountpoint: "/tank"},
}, },
storagePoolManager: zm, zfsManager: zm,
} }
var stats system.Stats var stats system.Stats
@@ -44,8 +43,8 @@ func TestUpdateDiskUsageZfsMountpoint(t *testing.T) {
// TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values // TestUpdateDiskUsageZfsRootPopulatesSystemStats verifies the root disk values
// are derived from ZFS usage when the root mountpoint is a ZFS dataset. // are derived from ZFS usage when the root mountpoint is a ZFS dataset.
func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) { func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}} zm := &ZfsManager{}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{ return []zfs.Dataset{
{Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"}, {Name: "rpool/ROOT/pve-1", Used: 900000000000, Avail: 300000000000, Mountpoint: "/"},
}, nil }, nil
@@ -54,7 +53,7 @@ func TestUpdateDiskUsageZfsRootPopulatesSystemStats(t *testing.T) {
fsStats: map[string]*system.FsStats{ fsStats: map[string]*system.FsStats{
"rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"}, "rpool/ROOT/pve-1": {Root: true, Mountpoint: "/"},
}, },
storagePoolManager: zm, zfsManager: zm,
} }
var stats system.Stats var stats system.Stats
@@ -86,8 +85,8 @@ func TestUpdateDiskUsageWithoutZfsManager(t *testing.T) {
// TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are // TestInitializeDiskIoStatsSkipsZfsMountpoints verifies ZFS filesystems are
// excluded from diskstats I/O tracking instead of warning about a missing device. // excluded from diskstats I/O tracking instead of warning about a missing device.
func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) { func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}} zm := &ZfsManager{}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil return []zfs.Dataset{{Name: "tank", Mountpoint: "/tank"}}, nil
} }
agent := &Agent{ agent := &Agent{
@@ -95,8 +94,8 @@ func TestInitializeDiskIoStatsSkipsZfsMountpoints(t *testing.T) {
"tank": {Root: false, Mountpoint: "/tank"}, "tank": {Root: false, Mountpoint: "/tank"},
"sda1": {Root: false, Mountpoint: "/mnt/data"}, "sda1": {Root: false, Mountpoint: "/mnt/data"},
}, },
storagePoolManager: zm, zfsManager: zm,
diskPrev: make(map[uint16]map[string]prevDisk), diskPrev: make(map[uint16]map[string]prevDisk),
} }
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{ agent.initializeDiskIoStats(map[string]disk.IOCountersStat{

View File

@@ -65,14 +65,10 @@ type dockerManager struct {
dockerVersionChecked bool // Whether a version probe has completed successfully dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman usingPodman bool // Whether the Docker Engine API is running on Podman
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress
// Cache-time-aware tracking for CPU stats (similar to cpu.go) // Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking // Maps cache time intervals to container-specific CPU usage tracking
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
@@ -165,9 +161,6 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
clear(dm.validIds) clear(dm.validIds)
} }
// Only schedule auxiliary work here; metrics never wait for image discovery.
dm.refreshImageUpdates(dm.apiContainerList, time.Now())
var failedContainers []*container.ApiInfo var failedContainers []*container.ApiInfo
for _, ctr := range dm.apiContainerList { for _, ctr := range dm.apiContainerList {
@@ -513,17 +506,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
} }
} }
// Read and decode the response before locking shared stats to avoid blocking
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("container stats request failed: %s", resp.Status)
}
res := &container.ApiStats{}
if err := json.NewDecoder(resp.Body).Decode(res); err != nil {
return err
}
updateAvailable := dm.cachedImageUpdate(ctr.Image)
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
defer dm.containerStatsMutex.Unlock() defer dm.containerStatsMutex.Unlock()
@@ -538,9 +520,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
stats.Status = statusText stats.Status = statusText
stats.Health = health stats.Health = health
stats.Image = ctr.Image
stats.UpdateAvailable = updateAvailable
if len(ctr.Ports) > 0 { if len(ctr.Ports) > 0 {
stats.Ports = convertContainerPortsToString(ctr) stats.Ports = convertContainerPortsToString(ctr)
} }
@@ -553,6 +532,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
stats.NetworkSent = 0 stats.NetworkSent = 0
stats.NetworkRecv = 0 stats.NetworkRecv = 0
res := dm.apiStats
res.Networks = nil
if err := dm.decode(resp, res); err != nil {
return err
}
// Initialize CPU tracking for this cache time interval // Initialize CPU tracking for this cache time interval
dm.initializeCpuTracking(cacheTimeMs) dm.initializeCpuTracking(cacheTimeMs)
@@ -710,6 +695,7 @@ func newDockerManager(agent *Agent) *dockerManager {
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{}, apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
excludeContainers: excludeContainers, excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures // Initialize cache-time-aware tracking structures

View File

@@ -1,105 +0,0 @@
package agent
import (
"log/slog"
"sync"
"time"
"github.com/distribution/reference"
"github.com/henrygd/beszel/internal/entities/container"
)
const imageUpdateInterval = time.Hour
type imageUpdateStatus struct {
available bool
checkedAt time.Time
}
func normalizedImageReference(image string) string {
named, err := reference.ParseNormalizedNamed(image)
if err != nil {
return ""
}
// Digest-pinned references cannot move to a new version.
if _, pinned := named.(reference.Digested); pinned {
return ""
}
return reference.TagNameOnly(named).String()
}
// refreshImageUpdates starts at most one background batch. Neither its network
// work nor its completion is part of the container metrics wait group.
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
dm.imageUpdatesMutex.Lock()
defer dm.imageUpdatesMutex.Unlock()
if dm.imageUpdatesRunning {
return
}
if dm.imageUpdates == nil {
dm.imageUpdates = make(map[string]*imageUpdateStatus)
}
active := make(map[string]struct{}, len(containers))
pending := make(map[string]*imageUpdateStatus)
for _, ctr := range containers {
if len(ctr.Names) > 0 && dm.shouldExcludeContainer(ctr.Names[0][1:]) {
continue
}
key := normalizedImageReference(ctr.Image)
if key == "" {
continue
}
active[key] = struct{}{}
entry := dm.imageUpdates[key]
if entry == nil {
entry = &imageUpdateStatus{}
dm.imageUpdates[key] = entry
}
if entry.checkedAt.IsZero() || now.Sub(entry.checkedAt) >= imageUpdateInterval {
pending[key] = entry
}
}
for key := range dm.imageUpdates {
if _, ok := active[key]; !ok {
delete(dm.imageUpdates, key)
}
}
if len(pending) == 0 {
return
}
dm.imageUpdatesRunning = true
go func() {
// Limit auxiliary requests even on hosts running many different images.
sem := make(chan struct{}, 2)
var wg sync.WaitGroup
for key, entry := range pending {
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
available, err := dm.checkImageUpdate(key)
if err != nil {
available = false
slog.Debug("Image update check failed", "image", key, "err", err)
}
dm.imageUpdatesMutex.Lock()
entry.available = available
entry.checkedAt = time.Now()
dm.imageUpdatesMutex.Unlock()
}()
}
wg.Wait()
dm.imageUpdatesMutex.Lock()
dm.imageUpdatesRunning = false
dm.imageUpdatesMutex.Unlock()
}()
}
func (dm *dockerManager) cachedImageUpdate(image string) bool {
key := normalizedImageReference(image)
dm.imageUpdatesMutex.RLock()
defer dm.imageUpdatesMutex.RUnlock()
entry := dm.imageUpdates[key]
return entry != nil && entry.available
}

View File

@@ -1,225 +0,0 @@
//go:build testing
package agent
import (
"encoding/json"
"fmt"
"github.com/fxamacker/cbor/v2"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/stretchr/testify/require"
)
func waitForImageUpdates(t *testing.T, dm *dockerManager) {
t.Helper()
require.Eventually(t, func() bool {
dm.imageUpdatesMutex.RLock()
defer dm.imageUpdatesMutex.RUnlock()
return !dm.imageUpdatesRunning
}, time.Second*3, time.Millisecond)
}
func TestImageUpdateCacheAndStats(t *testing.T) {
local := "sha256:" + strings.Repeat("a", 64)
remote := "sha256:" + strings.Repeat("b", 64)
var inspections, lookups atomic.Int32
var fail atomic.Bool
var upToDate atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/images/"):
inspections.Add(1)
fmt.Fprintf(w, `{"RepoDigests":["docker.io/library/nginx@%s"]}`, local)
case r.URL.Path == "/containers/json":
fmt.Fprint(w, `[{"Id":"aaaaaaaaaaaa","Names":["/one"],"Image":"nginx","Status":"Up 2 hours"},{"Id":"bbbbbbbbbbbb","Names":["/two"],"Image":"docker.io/library/nginx:latest","Status":"Up 2 hours"}]`)
case strings.Contains(r.URL.Path, "/stats"):
fmt.Fprint(w, `{"memory_stats":{"usage":1048576},"cpu_stats":{},"networks":{}}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
dm := newDockerManagerForVersionTest(server)
dm.dockerVersionChecked = true
dm.registryClient = &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if fail.Load() {
return nil, fmt.Errorf("registry unavailable")
}
response := &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"token":"test"}`))}
if r.Method == http.MethodHead {
lookups.Add(1)
digest := remote
if upToDate.Load() {
digest = local
}
response.Header.Set("Docker-Content-Digest", digest)
}
return response, nil
})}
stats, err := dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
require.Len(t, stats, 2)
waitForImageUpdates(t, dm)
require.EqualValues(t, 1, lookups.Load())
require.EqualValues(t, 1, inspections.Load())
stats, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
for _, stat := range stats {
require.True(t, stat.UpdateAvailable)
if stat.Id == "aaaaaaaaaaaa" {
require.Equal(t, "nginx", stat.Image)
} else {
require.Equal(t, "docker.io/library/nginx:latest", stat.Image)
}
}
require.EqualValues(t, 1, lookups.Load())
expire := func() {
dm.imageUpdatesMutex.Lock()
dm.imageUpdates["docker.io/library/nginx:latest"].checkedAt = time.Now().Add(-imageUpdateInterval)
dm.imageUpdatesMutex.Unlock()
}
upToDate.Store(true)
expire()
_, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
waitForImageUpdates(t, dm)
require.EqualValues(t, 2, lookups.Load())
require.False(t, dm.cachedImageUpdate("nginx:latest"))
// An expired positive result is cleared on failure, and the failure itself
// is cached so realtime stats do not retry a broken registry every second.
dm.imageUpdatesMutex.Lock()
dm.imageUpdates["docker.io/library/nginx:latest"].available = true
dm.imageUpdatesMutex.Unlock()
fail.Store(true)
expire()
_, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
waitForImageUpdates(t, dm)
failedInspections := inspections.Load()
stats, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
require.Len(t, stats, 2)
require.Equal(t, failedInspections, inspections.Load())
for _, stat := range stats {
require.False(t, stat.UpdateAvailable)
require.Equal(t, 1.0, stat.Mem)
}
}
func TestImageDiscoveryDoesNotBlockStats(t *testing.T) {
started := make(chan struct{}, 1)
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/images/") {
fmt.Fprintf(w, `{"RepoDigests":["example.com/app@sha256:%s"]}`, strings.Repeat("a", 64))
} else {
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
}
}))
defer server.Close()
dm := newDockerManagerForVersionTest(server)
defer func() { close(release); waitForImageUpdates(t, dm) }()
dm.registryClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
started <- struct{}{}
<-release
return nil, fmt.Errorf("timeout")
})}
ctr := &container.ApiInfo{IdShort: "aaaaaaaaaaaa", Image: "example.com/app", Names: []string{"/one"}}
dm.refreshImageUpdates([]*container.ApiInfo{ctr}, time.Now())
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("check did not start")
}
done := make(chan error, 1)
go func() { done <- dm.updateContainerStats(ctr, defaultCacheTimeMs) }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("registry blocked stats")
}
dm.imageUpdatesMutex.RLock()
require.True(t, dm.imageUpdatesRunning)
dm.imageUpdatesMutex.RUnlock()
}
func TestNormalizeImageUpdateReferences(t *testing.T) {
require.Equal(t, normalizedImageReference("nginx"), normalizedImageReference("docker.io/library/nginx:latest"))
require.Empty(t, normalizedImageReference("bad reference"))
require.Empty(t, normalizedImageReference("nginx@sha256:"+strings.Repeat("a", 64)))
}
// A stats request can return headers promptly and then stall while reading its
// body. The stats-map mutex must remain available during that read.
func TestStatsResponseBodyDoesNotHoldStatsLock(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.(http.Flusher).Flush()
close(started)
<-release
fmt.Fprint(w, `{"memory_stats":{"usage":1048576}}`)
}))
defer server.Close()
dm := newDockerManagerForVersionTest(server)
done := make(chan error, 1)
go func() {
done <- dm.updateContainerStats(&container.ApiInfo{IdShort: "aaaaaaaaaaaa", Names: []string{"/one"}, Image: "nginx"}, defaultCacheTimeMs)
}()
<-started
locked := make(chan struct{})
go func() { dm.containerStatsMutex.Lock(); dm.containerStatsMutex.Unlock(); close(locked) }()
select {
case <-locked:
case <-time.After(time.Second):
close(release)
<-done
t.Fatal("Docker response body held the stats mutex")
}
close(release)
require.NoError(t, <-done)
}
func TestImageUpdateStatsEncoding(t *testing.T) {
original := container.Stats{Image: "nginx:latest", UpdateAvailable: true}
encoded, err := cbor.Marshal(original)
require.NoError(t, err)
var fields map[int]any
require.NoError(t, cbor.Unmarshal(encoded, &fields))
require.Equal(t, true, fields[11])
require.Equal(t, "nginx:latest", fields[8])
var decoded container.Stats
require.NoError(t, cbor.Unmarshal(encoded, &decoded))
require.True(t, decoded.UpdateAvailable)
require.Equal(t, original.Image, decoded.Image)
encoded, err = json.Marshal(original)
require.NoError(t, err)
require.Contains(t, string(encoded), `"u":true`)
}
func TestImageUpdateCacheExpiryBoundaryAndPruning(t *testing.T) {
now := time.Now()
key := normalizedImageReference("nginx")
dm := &dockerManager{imageUpdates: map[string]*imageUpdateStatus{
key: {available: true, checkedAt: now},
"unused.example/image:latest": {checkedAt: now},
}}
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx"}}, now.Add(imageUpdateInterval-time.Nanosecond))
require.False(t, dm.imageUpdatesRunning)
require.Len(t, dm.imageUpdates, 1)
require.True(t, dm.cachedImageUpdate("nginx:latest"))
dm.refreshImageUpdates(nil, now)
require.Empty(t, dm.imageUpdates)
}

View File

@@ -1,222 +0,0 @@
package agent
import (
_ "crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/distribution/reference"
"github.com/opencontainers/go-digest"
)
const imageRegistryTimeout = 10 * time.Second
const imageManifestAccept = "application/vnd.docker.distribution.manifest.list.v2+json, " +
"application/vnd.docker.distribution.manifest.v2+json, " +
"application/vnd.oci.image.manifest.v1+json, " +
"application/vnd.oci.image.index.v1+json"
// checkImageUpdate compares the digest recorded by Docker for image with the
// digest currently advertised by its registry. A digest-pinned reference is
// immutable and therefore never has an update available.
func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
named, err := reference.ParseNormalizedNamed(image)
if err != nil {
return false, fmt.Errorf("parse image reference %q: %w", image, err)
}
if _, pinned := named.(reference.Digested); pinned {
return false, nil
}
named = reference.TagNameOnly(named)
registry := reference.Domain(named)
repository := reference.Path(named)
tag := named.(reference.Tagged).Tag()
localDigest, err := dm.inspectImageDigest(image, registry, repository)
if err != nil {
return false, err
}
remoteDigest, err := dm.registryImageDigest(registry, repository, tag)
if err != nil {
return false, err
}
return remoteDigest != localDigest, nil
}
// inspectImageDigest reads Docker's image metadata without using dm.decode.
// The checker runs in the image-discovery goroutine, so it must not hold any
// of the container statistics locks while waiting on the Docker API.
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
if dm.client == nil {
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
}
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
resp, err := dm.client.Get(endpoint)
if err != nil {
return "", fmt.Errorf("inspect image %q: %w", image, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
}
var inspect struct {
RepoDigests []string `json:"RepoDigests"`
}
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
}
if len(inspect.RepoDigests) == 0 {
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
}
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
if !ok {
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
}
return localDigest, nil
}
// matchingRepositoryDigest returns a valid digest belonging to the requested
// repository. Docker can return multiple RepoDigests for one local image; an
// unrelated first entry must never be used for the comparison.
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
for _, repoDigest := range repoDigests {
repoDigest = strings.TrimSpace(repoDigest)
at := strings.LastIndexByte(repoDigest, '@')
if at <= 0 || at == len(repoDigest)-1 || strings.Contains(repoDigest[:at], "@") {
continue
}
repoRef, err := reference.ParseNormalizedNamed(repoDigest[:at])
if err != nil || reference.Path(repoRef) != repository || !sameRegistry(reference.Domain(repoRef), registry) {
continue
}
if _, hasTag := repoRef.(reference.Tagged); hasTag {
continue
}
d, err := digest.Parse(repoDigest[at+1:])
if err != nil {
continue
}
return d.String(), true
}
return "", false
}
func sameRegistry(left, right string) bool {
left = canonicalRegistry(left)
right = canonicalRegistry(right)
return left == right ||
(left == "ghcr.io" && right == "lscr.io") ||
(left == "lscr.io" && right == "ghcr.io")
}
func canonicalRegistry(registry string) string {
if registry == "index.docker.io" {
return "docker.io"
}
return registry
}
func (dm *dockerManager) registryImageDigest(registry, repository, tag string) (string, error) {
client := dm.registryClient
if client == nil {
client = &http.Client{Timeout: imageRegistryTimeout}
}
token, err := dm.registryToken(client, registry, repository)
if err != nil {
return "", err
}
host := registry
if registry == "docker.io" {
host = "registry-1.docker.io"
}
manifestURL := "https://" + host + "/v2/" + repository + "/manifests/" + url.PathEscape(tag)
req, err := http.NewRequest(http.MethodHead, manifestURL, nil)
if err != nil {
return "", fmt.Errorf("create manifest request: %w", err)
}
req.Header.Set("Accept", imageManifestAccept)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch manifest %s:%s: %w", registry, repository, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("manifest request for %s:%s failed: %s", repository, tag, responseStatus(resp))
}
remote := strings.TrimSpace(resp.Header.Get("Docker-Content-Digest"))
d, err := digest.Parse(remote)
if err != nil {
return "", fmt.Errorf("manifest request for %s:%s returned invalid digest: %w", repository, tag, err)
}
return d.String(), nil
}
func (dm *dockerManager) registryToken(client *http.Client, registry, repository string) (string, error) {
var authURL string
switch registry {
case "docker.io":
authURL = "https://auth.docker.io/token?service=registry.docker.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
case "ghcr.io", "lscr.io":
// lscr.io is the LinuxServer alias for its GHCR-backed images.
authURL = "https://ghcr.io/token?service=ghcr.io&scope=" + url.QueryEscape("repository:"+repository+":pull")
default:
// Anonymous registries remain supported, as they were before the
// authenticated Docker Hub and GHCR paths were added.
return "", nil
}
req, err := http.NewRequest(http.MethodGet, authURL, nil)
if err != nil {
return "", fmt.Errorf("create registry auth request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch registry auth token for %s: %w", repository, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("registry auth request for %s failed: %s", repository, responseStatus(resp))
}
var tokenResponse struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
return "", fmt.Errorf("decode registry auth response for %s: %w", repository, err)
}
token := strings.TrimSpace(tokenResponse.Token)
if token == "" {
token = strings.TrimSpace(tokenResponse.AccessToken)
}
if token == "" {
return "", fmt.Errorf("registry auth response for %s contained no token", repository)
}
return token, nil
}
func responseStatus(resp *http.Response) string {
if resp.Status != "" {
return resp.Status
}
return http.StatusText(resp.StatusCode)
}

View File

@@ -1,204 +0,0 @@
//go:build testing
package agent
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
type registryTransportFunc func(*http.Request) (*http.Response, error)
func (fn registryTransportFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func registryResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
}
func registryDigest(fill byte) string {
return "sha256:" + strings.Repeat(string(fill), 64)
}
func newRegistryChecker(t *testing.T, inspectBody string, transport http.RoundTripper) *dockerManager {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/images/") {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, inspectBody)
return
}
http.NotFound(w, r)
}))
t.Cleanup(server.Close)
return &dockerManager{
client: newDockerManagerForVersionTest(server).client,
registryClient: &http.Client{Transport: transport},
}
}
func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
local := registryDigest('a')
remote := registryDigest('b')
var authCalls, manifestCalls atomic.Int32
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodGet && req.URL.Host == "auth.docker.io":
authCalls.Add(1)
require.Equal(t, "/token", req.URL.Path)
return registryResponse(http.StatusOK, `{"token":"test-token"}`), nil
case req.Method == http.MethodHead && req.URL.Host == "registry-1.docker.io":
manifestCalls.Add(1)
require.Equal(t, "/v2/library/alpine/manifests/latest", req.URL.Path)
require.Equal(t, "Bearer test-token", req.Header.Get("Authorization"))
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", remote)
return resp, nil
default:
return registryResponse(http.StatusNotFound, ""), nil
}
}))
available, err := dm.checkImageUpdate("alpine")
require.NoError(t, err)
require.True(t, available)
require.EqualValues(t, 1, authCalls.Load())
require.EqualValues(t, 1, manifestCalls.Load())
}
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
for _, test := range []struct {
name string
body string
}{
{name: "missing field", body: `{}`},
{name: "empty field", body: `{"RepoDigests":[]}`},
{name: "malformed reference", body: `{"RepoDigests":["not-a-repo-digest"]}`},
{name: "wrong repository", body: `{"RepoDigests":["docker.io/library/busybox@` + registryDigest('a') + `"]}`},
{name: "malformed digest", body: `{"RepoDigests":["docker.io/library/alpine@sha256:not-a-digest"]}`},
} {
t.Run(test.name, func(t *testing.T) {
var registryCalls atomic.Int32
dm := newRegistryChecker(t, test.body, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
registryCalls.Add(1)
return registryResponse(http.StatusOK, `{"token":"unexpected"}`), nil
}))
available, err := dm.checkImageUpdate("alpine")
require.Error(t, err)
require.False(t, available)
require.EqualValues(t, 0, registryCalls.Load(), "invalid local state must not query a registry")
})
}
}
func TestCheckImageUpdateChecksInspectAuthAndManifestStatuses(t *testing.T) {
local := registryDigest('a')
validInspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/alpine@%s"]}`, local)
tests := []struct {
name string
inspectCode int
authCode int
manifestCode int
remote string
want string
}{
{name: "inspect status", inspectCode: http.StatusNotFound, want: "inspect image"},
{name: "auth status", inspectCode: http.StatusOK, authCode: http.StatusUnauthorized, want: "registry auth"},
{name: "manifest status", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusNotFound, remote: local, want: "manifest request"},
{name: "missing digest", inspectCode: http.StatusOK, authCode: http.StatusOK, manifestCode: http.StatusOK, want: "invalid digest"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if test.inspectCode != http.StatusOK && strings.HasPrefix(r.URL.Path, "/images/") {
w.WriteHeader(test.inspectCode)
return
}
_, _ = io.WriteString(w, validInspect)
}))
t.Cleanup(server.Close)
calls := 0
dm := &dockerManager{client: newDockerManagerForVersionTest(server).client, registryClient: &http.Client{Transport: registryTransportFunc(func(req *http.Request) (*http.Response, error) {
calls++
if req.Method == http.MethodGet {
return registryResponse(test.authCode, `{"token":"test"}`), nil
}
response := registryResponse(test.manifestCode, "")
response.Header.Set("Docker-Content-Digest", test.remote)
return response, nil
})}}
_, err := dm.checkImageUpdate("alpine")
require.Error(t, err)
require.Contains(t, err.Error(), test.want)
if test.inspectCode != http.StatusOK {
require.Zero(t, calls)
}
})
}
}
func TestCheckImageUpdateSupportsAnonymousAndLSCRRegistries(t *testing.T) {
t.Run("anonymous registry", func(t *testing.T) {
local := registryDigest('a')
var calls atomic.Int32
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["example.com/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
require.Equal(t, http.MethodHead, req.Method)
require.Equal(t, "example.com", req.URL.Host)
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", local)
return resp, nil
}))
available, err := dm.checkImageUpdate("example.com/app")
require.NoError(t, err)
require.False(t, available)
require.EqualValues(t, 1, calls.Load())
})
t.Run("lscr ghcr alias", func(t *testing.T) {
local := registryDigest('a')
var authCalls, manifestCalls atomic.Int32
dm := newRegistryChecker(t, fmt.Sprintf(`{"RepoDigests":["ghcr.io/linuxserver/app@%s"]}`, local), registryTransportFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet {
authCalls.Add(1)
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
}
manifestCalls.Add(1)
require.Equal(t, "lscr.io", req.URL.Host)
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", local)
return resp, nil
}))
available, err := dm.checkImageUpdate("lscr.io/linuxserver/app")
require.NoError(t, err)
require.False(t, available)
require.EqualValues(t, 1, authCalls.Load())
require.EqualValues(t, 1, manifestCalls.Load())
})
}
func TestCheckImageUpdateSkipsPinnedDigest(t *testing.T) {
image := "docker.io/library/alpine@" + registryDigest('a')
dm := &dockerManager{}
available, err := dm.checkImageUpdate(image)
require.NoError(t, err)
require.False(t, available)
}

View File

@@ -1184,6 +1184,7 @@ func TestUpdateContainerStatsPodmanCpuCalculation(t *testing.T) {
} }
})}, })},
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
apiStats: &container.ApiStats{},
usingPodman: true, usingPodman: true,
lastCpuContainer: map[uint16]map[string]uint64{ lastCpuContainer: map[uint16]map[string]uint64{
defaultCacheTimeMs: {"0123456789ab": prevCpuUsage}, defaultCacheTimeMs: {"0123456789ab": prevCpuUsage},
@@ -1675,6 +1676,7 @@ func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
} }
})}, })},
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
apiStats: &container.ApiStats{},
usingPodman: true, usingPodman: true,
lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64),

View File

@@ -186,14 +186,14 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
type GetZfsDataHandler struct{} type GetZfsDataHandler struct{}
func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error { func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.storagePoolManager == nil { if hctx.Agent.zfsManager == nil {
return hctx.SendResponse(nil, hctx.RequestID) return hctx.SendResponse(nil, hctx.RequestID)
} }
var req common.ZfsDataRequest var req common.ZfsDataRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err return err
} }
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID) return hctx.SendResponse(hctx.Agent.zfsManager.GetDetail(req.Force), hctx.RequestID)
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////

View File

@@ -34,19 +34,19 @@ func TestNewAgentResponseSmartData(t *testing.T) {
func TestGetZfsDataHandlerForceRefresh(t *testing.T) { func TestGetZfsDataHandlerForceRefresh(t *testing.T) {
poolCalls := 0 poolCalls := 0
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}} zm := &ZfsManager{detailInterval: time.Hour}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
poolCalls++ poolCalls++
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
} }
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil } zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil } zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.GetDetail(false) zm.GetDetail(false)
requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true}) requestData, err := cbor.Marshal(common.ZfsDataRequest{Force: true})
assert.NoError(t, err) assert.NoError(t, err)
ctx := &HandlerContext{ ctx := &HandlerContext{
Agent: &Agent{storagePoolManager: zm}, Agent: &Agent{zfsManager: zm},
Request: &common.HubRequest[cbor.RawMessage]{ Request: &common.HubRequest[cbor.RawMessage]{
Action: common.GetZfsData, Action: common.GetZfsData,
Data: requestData, Data: requestData,

View File

@@ -931,6 +931,9 @@ func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (boo
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok { if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
rawValue = parsed rawValue = parsed
} }
if smartData.SmartStatus == "PASSED" && rawValue > 0 && (attr.ID == 5 || attr.ID == 197 || attr.ID == 198) {
smartData.SmartStatus = "WARNING"
}
smartAttr := &smart.SmartAttribute{ smartAttr := &smart.SmartAttribute{
ID: attr.ID, ID: attr.ID,
Name: attr.Name, Name: attr.Name,

View File

@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
@@ -89,6 +90,27 @@ func TestParseSmartForSata(t *testing.T) {
} }
} }
func TestParseSmartForSataWarnsForCriticalAttributes(t *testing.T) {
for _, attrID := range []int{5, 197, 198} {
t.Run("attribute "+strconv.Itoa(attrID), func(t *testing.T) {
jsonPayload := []byte(fmt.Sprintf(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sda", "type": "sat"},
"model_name": "Example",
"serial_number": "WARNING%d",
"smart_status": {"passed": true},
"temperature": {"current": 30},
"ata_smart_attributes": {"table": [{"id": %d, "raw": {"value": 1, "string": "1"}}]}
}`, attrID, attrID))
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
require.True(t, hasData)
assert.Equal(t, "WARNING", sm.SmartDataMap[fmt.Sprintf("WARNING%d", attrID)].SmartStatus)
})
}
}
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) { func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string

View File

@@ -1,462 +0,0 @@
package agent
import (
"errors"
"log/slog"
"os/exec"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
zfsentity "github.com/henrygd/beszel/internal/entities/zfs"
)
// zfsDatasetUsage holds usage values for a ZFS dataset mountpoint.
type zfsDatasetUsage struct {
used uint64
avail uint64
}
// datasetUsageRefreshInterval controls how often `zfs list` is re-run for the
// mountpoint usage map. Dataset inventory changes rarely.
const datasetUsageRefreshInterval = 5 * time.Minute
// poolStatsRefreshInterval controls how often `zpool list` is re-run for pool
// capacity. Health and I/O are read from procfs on Linux, so the utility only
// needs to refresh slow-moving space accounting.
const poolStatsRefreshInterval = time.Minute
// btrfsFilesystems is the btrfs source; overridable in tests.
var btrfsFilesystems = btrfs.Filesystems
type poolKernelSample struct {
nread uint64
nwrite uint64
at time.Time
}
// StoragePoolManager combines independent backend inventories. Metrics and
// dataset usage require the agent lock; GetDetail is safe for concurrent calls.
type StoragePoolManager struct {
backends []*poolBackend
detailInterval time.Duration
}
// poolBackend owns one backend's collectors and caches. Collector functions
// are immutable after construction and may run concurrently for metrics/details.
type poolBackend struct {
name string
poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
kernelSamples map[string]poolKernelSample
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
// an interval. Accessed from handler goroutines, so it is mutex-protected.
detailMu sync.Mutex
detail *zfsentity.ZfsData
lastDetailRefresh time.Time
detailFailed bool
}
func newStoragePoolManager() *StoragePoolManager {
return &StoragePoolManager{
backends: []*poolBackend{newZfsBackend(), newBtrfsBackend()},
detailInterval: time.Hour,
}
}
func newZfsBackend() *poolBackend {
return &poolBackend{
name: "zfs",
poolStatsFn: optionalPoolSource(zfs.PoolStats),
datasetsFn: optionalPoolSource(zfs.Datasets),
kernelStatsFn: optionalPoolSource(zfs.PoolKernelStats),
poolStatusesFn: optionalPoolSource(zfs.PoolStatuses),
}
}
func newBtrfsBackend() *poolBackend {
return &poolBackend{
name: "btrfs",
poolStatsFn: btrfsSource(btrfsPoolStats),
kernelStatsFn: btrfsSource(btrfsKernelStats),
poolStatusesFn: btrfsSource(btrfsPoolStatuses),
}
}
// datasets is optional: only backends that expose datasets provide a collector.
func (b *poolBackend) datasets() ([]zfs.Dataset, error) {
if b.datasetsFn == nil {
return nil, nil
}
return b.datasetsFn()
}
// A missing utility/interface is a successfully observed absent backend.
func optionalPoolSource[T any](source func() ([]T, error)) func() ([]T, error) {
return func() ([]T, error) {
items, err := source()
if errors.Is(err, zfs.ErrNoZfs) || errors.Is(err, exec.ErrNotFound) || errors.Is(err, errors.ErrUnsupported) {
return nil, nil
}
return items, err
}
}
func btrfsSource[T any](convert func(btrfs.Filesystem) T) func() ([]T, error) {
return func() ([]T, error) {
filesystems, err := optionalPoolSource(btrfsFilesystems)()
if err != nil {
return nil, err
}
items := make([]T, 0, len(filesystems))
for _, fs := range filesystems {
items = append(items, convert(fs))
}
return items, nil
}
}
// Update refreshes systemStats.ZfsPools with the latest pool data. I/O
// throughput and health come from inexpensive kernel kstats on Linux. Pool
// capacity and dataset usage come from separately cached utility calls. The
// pool map is empty when both backends are absent.
func (m *StoragePoolManager) Update(systemStats *system.Stats) {
// Rebuild the combined map so successful pool removals clear old samples.
systemStats.ZfsPools = nil
for _, backend := range m.backends {
backend.updateBackendStats(systemStats)
}
}
func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
pools := b.poolStats()
if len(pools) == 0 {
b.kernelSamples = nil
return
}
kernelStats, ioRates := b.kernelStats()
if systemStats.ZfsPools == nil {
systemStats.ZfsPools = make(map[string]*system.ZfsPool, len(pools))
}
for i := range pools {
pool := &pools[i]
// Full precision, matching the dataset values below; the frontend
// formats any magnitude.
stats := &system.ZfsPool{
DisplayName: pool.DisplayName,
Raw: pool.Raw,
Total: float64(pool.Size) / (1024 * 1024 * 1024),
Used: float64(pool.Alloc) / (1024 * 1024 * 1024),
Health: pool.Health,
}
if kernel, exists := kernelStats[pool.Name]; exists && kernel.Health != "" {
stats.Health = kernel.Health
}
if io, exists := ioRates[pool.Name]; exists {
stats.ReadBytes = io.NRead
stats.WriteBytes = io.NWrite
}
slog.Debug("Storage pool sample", "backend", b.name, "pool", pool.Name, "health", stats.Health, "used_gb", stats.Used, "read_bps", stats.ReadBytes, "write_bps", stats.WriteBytes)
systemStats.ZfsPools[pool.Name] = stats
}
}
// poolStats returns the cached pool inventory, calling its collector at most
// every poolStatsRefreshInterval. On failure the previous inventory is
// retained and the refresh is retried on the next cadence.
func (b *poolBackend) poolStats() []zfs.PoolStat {
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
pools, err := b.poolStatsFn()
if err != nil {
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
} else {
b.poolData = pools
}
b.lastPoolStats = time.Now()
}
return b.poolData
}
// kernelStats reads cumulative pool counters and converts them to per-second
// rates. Counter decreases indicate a pool export/import and reset the
// baseline instead of producing an underflow spike.
func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
if b.kernelStatsFn == nil {
return nil, nil
}
stats, err := b.kernelStatsFn()
if err != nil {
slog.Debug("Storage pool kernel stats unavailable", "backend", b.name, "err", err)
return nil, nil
}
now := time.Now()
byName := make(map[string]zfs.PoolKernelStat, len(stats))
rates := make(map[string]zfs.PoolIoStats, len(stats))
nextSamples := make(map[string]poolKernelSample, len(stats))
for _, stat := range stats {
byName[stat.Name] = stat
if previous, ok := b.kernelSamples[stat.Name]; ok && now.After(previous.at) &&
stat.NRead >= previous.nread && stat.NWrite >= previous.nwrite {
seconds := now.Sub(previous.at).Seconds()
rates[stat.Name] = zfs.PoolIoStats{
NRead: uint64(float64(stat.NRead-previous.nread) / seconds),
NWrite: uint64(float64(stat.NWrite-previous.nwrite) / seconds),
}
}
nextSamples[stat.Name] = poolKernelSample{nread: stat.NRead, nwrite: stat.NWrite, at: now}
}
b.kernelSamples = nextSamples
return byName, rates
}
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
// and rebuilds the mountpoint-keyed usage map.
func (b *poolBackend) refreshDatasetUsage() {
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
return
}
datasets, err := b.datasets()
if err != nil {
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
} else {
usage := make(map[string]zfsDatasetUsage, len(datasets))
for _, ds := range datasets {
if ds.Mountpoint != "" && ds.Mountpoint != "-" {
usage[ds.Mountpoint] = zfsDatasetUsage{used: ds.Used, avail: ds.Avail}
}
}
b.datasetUsage = usage
}
b.lastUsageRefresh = time.Now()
}
// DatasetUsage returns ZFS dataset usage keyed by mountpoint, refreshed at
// most every datasetUsageRefreshInterval. On failure the previous map is
// retained and a debug log is emitted.
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
for _, backend := range m.backends {
if backend.name == "zfs" {
backend.refreshDatasetUsage()
return backend.datasetUsage
}
}
return nil
}
// GetDetail combines backend snapshots, identifying successful inventories so
// the hub can accept partial updates without deleting failed backend records.
func (m *StoragePoolManager) GetDetail(force bool) *zfsentity.ZfsData {
data := &zfsentity.ZfsData{Complete: true}
for _, backend := range m.backends {
snapshot := backend.getBackendDetail(force, m.detailInterval)
data.Pools = append(data.Pools, snapshot.Pools...)
if snapshot.Complete {
data.CompleteBackends = append(data.CompleteBackends, backend.name)
} else {
data.Complete = false
}
}
return data
}
func (b *poolBackend) getBackendDetail(force bool, interval time.Duration) *zfsentity.ZfsData {
b.detailMu.Lock()
defer b.detailMu.Unlock()
if force || b.detailFailed || b.detail == nil || time.Since(b.lastDetailRefresh) >= interval {
if data, err := b.collectDetail(b.detail); err != nil {
b.detailFailed = true
slog.Debug("Storage pool detail collection failed", "backend", b.name, "err", err)
if b.detail == nil {
return &zfsentity.ZfsData{}
}
return &zfsentity.ZfsData{Pools: b.detail.Pools}
} else {
b.detailFailed = false
b.detail = data
b.lastDetailRefresh = time.Now()
}
}
if b.detail == nil {
return &zfsentity.ZfsData{}
}
return b.detail
}
// collectDetail builds a ZfsData payload from the current system state.
func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
pools, err := b.poolStatsFn()
if err != nil {
return nil, err
}
if len(pools) == 0 {
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
}
statuses, statusErr := b.poolStatusesFn()
if statusErr != nil {
slog.Debug("Storage pool status unavailable", "backend", b.name, "err", statusErr)
}
datasets, datasetsErr := b.datasets()
if datasetsErr != nil {
slog.Debug("Storage pool datasets unavailable", "backend", b.name, "err", datasetsErr)
}
statusByPool := make(map[string]zfs.PoolStatus, len(statuses))
for _, st := range statuses {
statusByPool[st.Name] = st
}
previousByPool := make(map[string]*zfsentity.PoolDetail)
if previous != nil {
for _, pool := range previous.Pools {
if pool != nil {
previousByPool[pool.Name] = pool
}
}
}
data := &zfsentity.ZfsData{Pools: make([]*zfsentity.PoolDetail, 0, len(pools)), Complete: true}
for i := range pools {
p := &pools[i]
detail := &zfsentity.PoolDetail{
DisplayName: p.DisplayName,
Raw: p.Raw,
Name: p.Name,
Health: p.Health,
Size: p.Size,
Alloc: p.Alloc,
Free: p.Free,
}
if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
if st.Scrub.State != "" && st.Scrub.State != "NONE" {
detail.Scrub = &zfsentity.Scrub{
State: st.Scrub.State,
Progress: st.Scrub.Progress,
Errors: st.Scrub.Errors,
}
}
for _, v := range st.Vdevs {
detail.Vdevs = append(detail.Vdevs, &zfsentity.Vdev{
Name: v.Name,
State: v.State,
ReadErrs: v.ReadErrs,
WriteErrs: v.WriteErrs,
ChecksumErrs: v.ChecksumErrs,
})
}
} else {
if cached := previousByPool[p.Name]; cached != nil {
detail.Scrub = cached.Scrub
detail.Vdevs = cached.Vdevs
}
}
if datasetsErr == nil {
foundDataset := false
for _, ds := range datasets {
if poolOfDataset(ds.Name) == p.Name {
foundDataset = true
detail.Datasets = append(detail.Datasets, &zfsentity.Dataset{
Name: ds.Name,
Used: ds.Used,
Avail: ds.Avail,
Mountpoint: ds.Mountpoint,
})
}
}
if !foundDataset {
if cached := previousByPool[p.Name]; cached != nil {
detail.Datasets = cached.Datasets
}
}
} else if cached := previousByPool[p.Name]; cached != nil {
detail.Datasets = cached.Datasets
}
data.Pools = append(data.Pools, detail)
}
return data, nil
}
// poolOfDataset returns the pool name for a dataset name (everything before
// the first '/'). Datasets without a separator belong to a pool of the same
// name.
func poolOfDataset(name string) string {
if idx := strings.IndexByte(name, '/'); idx >= 0 {
return name[:idx]
}
return name
}
// ZfsMountpoints returns the set of mountpoints backed by ZFS datasets.
func (m *StoragePoolManager) ZfsMountpoints() map[string]bool {
usage := m.DatasetUsage()
mountpoints := make(map[string]bool, len(usage))
for mountpoint := range usage {
mountpoints[mountpoint] = true
}
return mountpoints
}
func btrfsPoolStats(fs btrfs.Filesystem) zfs.PoolStat {
return zfs.PoolStat{MountID: fs.MountID, IODevice: fs.IODevice, Raw: fs.Raw, DisplayName: fs.Name, Name: "b:" + fs.UUID, Size: fs.Size, Alloc: fs.Alloc, Free: fs.Size - min(fs.Alloc, fs.Size), Health: fs.Health}
}
func btrfsKernelStats(fs btrfs.Filesystem) zfs.PoolKernelStat {
return zfs.PoolKernelStat{Name: "b:" + fs.UUID, Health: fs.Health, NRead: fs.NRead, NWrite: fs.NWrite}
}
func btrfsPoolStatuses(fs btrfs.Filesystem) zfs.PoolStatus {
status := zfs.PoolStatus{Name: "b:" + fs.UUID, State: fs.Health, Scrub: zfs.ScrubStatus{State: "NONE"}}
for _, dev := range fs.Devices {
status.Vdevs = append(status.Vdevs, zfs.VdevStatus{
Name: dev.Name, State: dev.State,
ReadErrs: dev.ReadErrs, WriteErrs: dev.WriteErrs, ChecksumErrs: dev.CorruptionErrs,
})
}
return status
}
// markDuplicateCharts leaves pool telemetry and detail intact, but tells the
// hub which charts already have a filesystem equivalent. Only exact kernel
// filesystem and I/O-device matches qualify; labels are never used.
func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystems map[string]*system.FsStats, mountID func(string) string) {
identities := make(map[string]string, len(filesystems))
for device, fs := range filesystems {
if fs.DiskTotal > 0 {
identities[device] = mountID(fs.Mountpoint)
}
}
for _, backend := range m.backends {
for _, pool := range backend.poolData {
sample := stats.ZfsPools[pool.Name]
if sample == nil || pool.MountID == "" {
continue
}
for device, identity := range identities {
if identity != pool.MountID {
continue
}
// Raw physical usage is not equivalent to a filesystem usage chart.
sample.HideUsage = !pool.Raw
if pool.IODevice != "" && pool.IODevice == device {
sample.HideIO = true
}
}
}
}
}

View File

@@ -1,520 +0,0 @@
//go:build testing
package agent
import (
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"testing"
"time"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOptionalPoolSource(t *testing.T) {
failure := errors.New("timeout")
for _, err := range []error{nil, zfs.ErrNoZfs, fmt.Errorf("zpool: %w", exec.ErrNotFound), errors.ErrUnsupported, failure} {
_, got := optionalPoolSource(func() ([]zfs.PoolStat, error) { return nil, err })()
if err == failure {
assert.ErrorIs(t, got, failure)
} else {
assert.NoError(t, got)
}
}
}
type poolTestBackend struct {
name string
err error
alloc uint64
read uint64
empty bool
}
func (state *poolTestBackend) backend() *poolBackend {
name := "zfs"
if strings.HasPrefix(state.name, "b:") {
name = "btrfs"
}
return &poolBackend{
name: name,
poolStatsFn: func() ([]zfs.PoolStat, error) {
if state.empty {
return nil, state.err
}
return []zfs.PoolStat{{Name: state.name, Size: 100, Alloc: state.alloc}}, state.err
},
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) {
return []zfs.PoolKernelStat{{Name: state.name, NRead: state.read}}, state.err
},
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil },
}
}
func TestIndependentPoolBackendCaches(t *testing.T) {
for _, failed := range []int{0, 1} {
t.Run([]string{"zfs", "btrfs"}[failed], func(t *testing.T) {
states := []*poolTestBackend{{name: "tank", alloc: 10}, {name: "b:uuid", alloc: 10}}
managers := []*poolBackend{states[0].backend(), states[1].backend()}
zm := &StoragePoolManager{backends: managers, detailInterval: time.Hour}
var stats system.Stats
zm.Update(&stats)
require.Len(t, stats.ZfsPools, 2)
require.True(t, zm.GetDetail(true).Complete)
baseline := poolKernelSample{at: time.Now().Add(-time.Second)}
for i, m := range managers {
m.lastPoolStats = time.Time{}
m.kernelSamples[states[i].name] = baseline
states[i].alloc = 20
states[i].read = 100
}
states[failed].err = errors.New("collection failed")
zm.Update(&stats)
healthy := 1 - failed
assert.Equal(t, uint64(10), managers[failed].poolData[0].Alloc)
assert.Equal(t, uint64(20), managers[healthy].poolData[0].Alloc)
assert.Equal(t, baseline, managers[failed].kernelSamples[states[failed].name])
assert.Zero(t, stats.ZfsPools[states[failed].name].ReadBytes)
assert.Positive(t, stats.ZfsPools[states[healthy].name].ReadBytes)
partial := zm.GetDetail(true)
assert.False(t, partial.Complete)
assert.False(t, partial.CanRefreshPool(states[failed].name))
assert.True(t, partial.CanRefreshPool(states[healthy].name))
assert.Equal(t, uint64(10), partial.Pools[failed].Alloc)
assert.Equal(t, uint64(20), partial.Pools[healthy].Alloc)
assert.False(t, zm.GetDetail(false).Complete, "a failed forced refresh must not become complete from cache")
// Successful empty inventory removes only the healthy backend's pool.
states[healthy].empty = true
managers[healthy].lastPoolStats = time.Time{}
zm.Update(&stats)
require.Len(t, stats.ZfsPools, 1)
assert.Contains(t, stats.ZfsPools, states[failed].name)
partial = zm.GetDetail(true)
require.Len(t, partial.Pools, 1)
assert.True(t, partial.CanRefreshPool(states[healthy].name))
// Recovery uses the retained I/O baseline, then normal removal works.
states[failed].err = nil
managers[failed].lastPoolStats = time.Time{}
zm.Update(&stats)
assert.Positive(t, stats.ZfsPools[states[failed].name].ReadBytes)
assert.True(t, zm.GetDetail(true).Complete)
states[failed].empty = true
managers[failed].lastPoolStats = time.Time{}
zm.Update(&stats)
assert.Empty(t, stats.ZfsPools)
assert.Empty(t, zm.GetDetail(true).Pools)
})
}
}
func TestIndependentBackendsWithoutCache(t *testing.T) {
z := &poolTestBackend{name: "tank", err: errors.New("ZFS failure")}
b := &poolTestBackend{name: "b:uuid", alloc: 20}
zm := &StoragePoolManager{backends: []*poolBackend{z.backend(), b.backend()}, detailInterval: time.Hour}
var stats system.Stats
zm.Update(&stats)
require.Len(t, stats.ZfsPools, 1)
assert.Contains(t, stats.ZfsPools, "b:uuid")
detail := zm.GetDetail(true)
require.Len(t, detail.Pools, 1)
assert.False(t, detail.Complete)
assert.Equal(t, []string{"btrfs"}, detail.CompleteBackends)
}
func TestConcurrentBackendDetailsAndMetrics(t *testing.T) {
zm := &StoragePoolManager{backends: []*poolBackend{(&poolTestBackend{name: "tank"}).backend(), (&poolTestBackend{name: "b:uuid"}).backend()}, detailInterval: time.Hour}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(metrics bool) {
defer wg.Done()
for j := 0; j < 10; j++ {
if metrics {
zm.Update(&system.Stats{})
} else {
zm.GetDetail(true)
}
}
}(i == 0)
}
wg.Wait()
}
func TestStoragePoolBackendOrder(t *testing.T) {
z := (&poolTestBackend{name: "tank"}).backend()
b := (&poolTestBackend{name: "b:uuid"}).backend()
b.datasetsFn = nil // Btrfs does not expose datasets.
z.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank/data", Mountpoint: "/tank", Used: 10}}, nil
}
m := &StoragePoolManager{backends: []*poolBackend{b, z}, detailInterval: time.Hour}
var stats system.Stats
m.Update(&stats)
require.Len(t, stats.ZfsPools, 2)
detail := m.GetDetail(true)
require.True(t, detail.Complete)
assert.Equal(t, []string{"btrfs", "zfs"}, detail.CompleteBackends)
assert.Empty(t, detail.Pools[0].Datasets)
assert.Len(t, detail.Pools[1].Datasets, 1)
assert.Equal(t, uint64(10), m.DatasetUsage()["/tank"].used)
b.poolData[0].MountID = "uuid"
b.poolData[0].IODevice = "sda"
calls := 0
m.markDuplicateCharts(&stats, map[string]*system.FsStats{
"sda": {Mountpoint: "/", DiskTotal: 100},
}, func(string) string { calls++; return "uuid" })
assert.Equal(t, 1, calls, "resolve each filesystem once across all backends")
assert.True(t, stats.ZfsPools["b:uuid"].HideUsage)
assert.True(t, stats.ZfsPools["b:uuid"].HideIO)
}
func TestUpdatePopulatesZfsPools(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{
{Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
{Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
// Small zvol (Proxmox VM EFI disk): must not round to zero.
{Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
}, nil
}
var kernelCalls int
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
kernelCalls++
return []zfs.PoolKernelStat{{
Name: "tank", Health: "ONLINE",
NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
}}, nil
}
var stats system.Stats
// The first kernel sample establishes the cumulative-counter baseline.
zm.Update(&stats)
zm.backends[0].kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
zm.Update(&stats)
require.NotNil(t, stats.ZfsPools)
require.Contains(t, stats.ZfsPools, "tank")
assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
}
// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
// I/O instead of erroring.
func TestUpdateKernelStatsMissing(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
return nil, zfs.ErrNoZfs
}
var stats system.Stats
zm.Update(&stats)
require.NotNil(t, stats.ZfsPools)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
}
func TestUpdateKernelCounterReset(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.backends[0].kernelSamples = map[string]poolKernelSample{
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
}
zm.backends[0].kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
}
var stats system.Stats
zm.Update(&stats)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
}
func TestUpdateNoZfs(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
calls := 0
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
calls++
return nil, zfs.ErrNoZfs
}
var stats system.Stats
zm.Update(&stats)
zm.Update(&stats)
assert.Nil(t, stats.ZfsPools)
assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
}
func TestUpdateEmptyPools(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
calls := 0
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
calls++
return nil, nil
}
var stats system.Stats
zm.Update(&stats)
zm.Update(&stats)
assert.Nil(t, stats.ZfsPools)
assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
}
func TestDatasetUsage(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
calls := 0
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
calls++
return []zfs.Dataset{
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
{Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
{Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
}, nil
}
usage := zm.DatasetUsage()
require.Len(t, usage, 2)
assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
assert.Equal(t, 1, calls)
// Second call within the refresh window must not re-run the collector.
zm.DatasetUsage()
assert.Equal(t, 1, calls)
}
func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
}
assert.Len(t, zm.DatasetUsage(), 1)
// Force refresh window expiry, then a failing collector.
zm.backends[0].lastUsageRefresh = time.Now().Add(-10 * time.Minute)
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return nil, zfs.ErrNoZfs
}
usage := zm.DatasetUsage()
assert.Len(t, usage, 1, "previous usage should be retained on error")
}
func TestDatasetUsageClearsAbsentBackend(t *testing.T) {
b := newZfsBackend()
b.datasetUsage = map[string]zfsDatasetUsage{"/tank": {used: 1, avail: 1}}
b.datasetsFn = optionalPoolSource(func() ([]zfs.Dataset, error) {
return nil, zfs.ErrNoZfs
})
datasets, err := b.datasets()
require.NoError(t, err, "an absent backend must not produce an error to log")
assert.Empty(t, datasets)
b.refreshDatasetUsage()
assert.Empty(t, b.datasetUsage)
assert.False(t, b.lastUsageRefresh.IsZero())
}
func TestGetDetailForceRefresh(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
poolCalls := 0
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
poolCalls++
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
}
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
first := zm.GetDetail(false)
assert.True(t, first.Complete)
require.Len(t, first.Pools, 1)
assert.Equal(t, uint64(1), first.Pools[0].Alloc)
cached := zm.GetDetail(false)
require.Len(t, cached.Pools, 1)
assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
assert.Equal(t, 1, poolCalls)
refreshed := zm.GetDetail(true)
assert.True(t, refreshed.Complete)
require.Len(t, refreshed.Pools, 1)
assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
assert.Equal(t, 2, poolCalls)
}
func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
require.Len(t, zm.GetDetail(false).Pools, 1)
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
empty := zm.GetDetail(true)
assert.True(t, empty.Complete)
assert.Empty(t, empty.Pools)
}
func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) {
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank/data"}}, nil
}
first := zm.GetDetail(false)
require.True(t, first.Complete)
require.Len(t, first.Pools[0].Vdevs, 1)
require.Len(t, first.Pools[0].Datasets, 1)
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
partial := zm.GetDetail(true)
require.True(t, partial.Complete)
require.Len(t, partial.Pools[0].Vdevs, 1)
require.Len(t, partial.Pools[0].Datasets, 1)
zm.backends[0].poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
lastSuccessfulRefresh := zm.backends[0].lastDetailRefresh
failed := zm.GetDetail(true)
assert.False(t, failed.Complete)
require.Len(t, failed.Pools, 1)
assert.Equal(t, "tank", failed.Pools[0].Name)
assert.Equal(t, lastSuccessfulRefresh, zm.backends[0].lastDetailRefresh)
}
func TestZfsMountpoints(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs"}}}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{
{Name: "tank", Mountpoint: "/tank"},
{Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
}, nil
}
mountpoints := zm.ZfsMountpoints()
assert.Len(t, mountpoints, 2)
assert.True(t, mountpoints["/tank"])
assert.True(t, mountpoints["/"])
}
func TestBtrfsRawCapacityPropagates(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{btrfsPoolStats(btrfs.Filesystem{UUID: "raw", Name: "raw", Size: 200, Alloc: 100, Raw: true})}, nil
},
poolStatusesFn: func() ([]zfs.PoolStatus, error) { return nil, nil },
datasetsFn: func() ([]zfs.Dataset, error) { return nil, nil }}}}
var stats system.Stats
zm.Update(&stats)
require.True(t, stats.ZfsPools["b:raw"].Raw)
detail := zm.GetDetail(true)
require.True(t, detail.Complete)
require.Len(t, detail.Pools, 1)
assert.True(t, detail.Pools[0].Raw)
}
func TestMarkDuplicatePoolCharts(t *testing.T) {
for _, tc := range []struct {
name, poolID, device string
raw bool
diskTotal float64
wantUsage, wantIO bool
}{
{"single device root", "fs1", "dm-0", false, 100, true, true},
{"multi device", "fs1", "", false, 100, true, false},
{"different IO device", "fs1", "nvme0n1", false, 100, true, false},
{"different filesystem", "fs2", "dm-0", false, 100, false, false},
{"unknown identity", "", "dm-0", false, 100, false, false},
{"raw usage", "fs1", "dm-0", true, 100, false, true},
{"failed disk collection", "fs1", "dm-0", false, 0, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolData: []zfs.PoolStat{{Name: "arbitrary label", MountID: tc.poolID, IODevice: tc.device, Raw: tc.raw}}}}}
stats := &system.Stats{ZfsPools: map[string]*system.ZfsPool{"arbitrary label": {}}}
fs := map[string]*system.FsStats{"dm-0": {Root: true, Mountpoint: "/", DiskTotal: tc.diskTotal}}
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
// Bind mounts and custom extra-filesystem names have the same identity.
fs["dm-0"].Root = false
fs["dm-0"].Mountpoint = "/extra-filesystems/storage"
fs["dm-0"].Name = "custom name"
stats.ZfsPools["arbitrary label"] = &system.ZfsPool{}
zm.markDuplicateCharts(stats, fs, func(string) string { return "fs1" })
assert.Equal(t, tc.wantUsage, stats.ZfsPools["arbitrary label"].HideUsage)
assert.Equal(t, tc.wantIO, stats.ZfsPools["arbitrary label"].HideIO)
})
}
}
func TestBtrfsPoolIdentities(t *testing.T) {
old := btrfsFilesystems
t.Cleanup(func() { btrfsFilesystems = old })
label := "tank"
btrfsFilesystems = func() ([]btrfs.Filesystem, error) {
return []btrfs.Filesystem{
{UUID: "11111111-1111-4111-8111-111111111111", Name: label, Size: 100, Health: "ONLINE", NRead: 100, Devices: []btrfs.Device{{Name: "first"}}},
{UUID: "22222222-2222-4222-8222-222222222222", Name: "tank", Size: 200, Health: "DEGRADED", NRead: 200, Devices: []btrfs.Device{{Name: "second"}}},
}, nil
}
zm := &StoragePoolManager{detailInterval: time.Hour, backends: []*poolBackend{{name: "zfs", poolStatsFn: func() ([]zfs.PoolStat, error) { return []zfs.PoolStat{{Name: "tank", Size: 300}}, nil },
kernelStatsFn: func() ([]zfs.PoolKernelStat, error) { return []zfs.PoolKernelStat{{Name: "tank", NRead: 300}}, nil },
poolStatusesFn: func() ([]zfs.PoolStatus, error) {
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "zfs-device"}}}}, nil
},
datasetsFn: func() ([]zfs.Dataset, error) { return []zfs.Dataset{{Name: "tank/data"}}, nil }}, newBtrfsBackend()}}
first := "b:11111111-1111-4111-8111-111111111111"
second := "b:22222222-2222-4222-8222-222222222222"
var stats system.Stats
zm.Update(&stats)
require.Len(t, stats.ZfsPools, 3)
assert.Contains(t, stats.ZfsPools, "tank")
assert.Equal(t, "ONLINE", stats.ZfsPools[first].Health)
assert.Equal(t, "DEGRADED", stats.ZfsPools[second].Health)
assert.Equal(t, uint64(100), zm.backends[1].kernelSamples[first].nread)
assert.Equal(t, uint64(200), zm.backends[1].kernelSamples[second].nread)
detail := zm.GetDetail(true)
require.Len(t, detail.Pools, 3)
assert.Equal(t, "zfs-device", detail.Pools[0].Vdevs[0].Name)
assert.Len(t, detail.Pools[0].Datasets, 1)
assert.Equal(t, "first", detail.Pools[1].Vdevs[0].Name)
assert.Empty(t, detail.Pools[1].Datasets)
assert.Equal(t, "second", detail.Pools[2].Vdevs[0].Name)
label = "renamed"
zm.backends[0].lastPoolStats = time.Time{}
zm.backends[1].lastPoolStats = time.Time{}
zm.Update(&stats)
require.Len(t, stats.ZfsPools, 3)
assert.Equal(t, "renamed", stats.ZfsPools[first].DisplayName)
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery" "github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
@@ -220,9 +219,8 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// disk i/o (cache-aware per interval) // disk i/o (cache-aware per interval)
a.updateDiskIo(cacheTimeMs, &systemStats) a.updateDiskIo(cacheTimeMs, &systemStats)
// storage pool stats // zfs pool stats
a.storagePoolManager.Update(&systemStats) a.zfsManager.Update(&systemStats)
a.storagePoolManager.markDuplicateCharts(&systemStats, a.fsStats, btrfs.MountID)
// network stats (per cache interval) // network stats (per cache interval)
a.updateNetworkStats(cacheTimeMs, &systemStats) a.updateNetworkStats(cacheTimeMs, &systemStats)

View File

@@ -33,15 +33,11 @@ var ErrNoZfs = errors.New("zfs utilities unavailable")
// PoolStat is a snapshot of a ZFS pool's capacity and health. // PoolStat is a snapshot of a ZFS pool's capacity and health.
type PoolStat struct { type PoolStat struct {
DisplayName string // optional friendly name; Name remains the stable key Name string
MountID string // Btrfs filesystem identity, empty for other backends Size uint64 // total capacity in bytes
IODevice string // sole Btrfs member device, if known Alloc uint64 // allocated bytes
Raw bool // physical accounting rather than usable filesystem space Free uint64 // free bytes
Name string Health string // ONLINE, DEGRADED, FAULTED, ...
Size uint64 // total capacity in bytes
Alloc uint64 // allocated bytes
Free uint64 // free bytes
Health string // ONLINE, DEGRADED, FAULTED, ...
} }
// PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel. // PoolKernelStat is the inexpensive pool telemetry exposed by the ZFS kernel.
@@ -70,9 +66,6 @@ type Dataset struct {
// PoolStats returns capacity and health for all pools on the system using // PoolStats returns capacity and health for all pools on the system using
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead. // `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
func PoolStats() ([]PoolStat, error) { func PoolStats() ([]PoolStat, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
}
out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health") out, err := commandOutput("zpool", "list", "-Hp", "-o", "name,size,alloc,free,health")
if err != nil { if err != nil {
var exitErr *exec.ExitError var exitErr *exec.ExitError
@@ -87,9 +80,6 @@ func PoolStats() ([]PoolStat, error) {
// Datasets returns all datasets on the system with usage and mountpoint // Datasets returns all datasets on the system with usage and mountpoint
// information using `zfs list` (recursive by default). // information using `zfs list` (recursive by default).
func Datasets() ([]Dataset, error) { func Datasets() ([]Dataset, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
}
out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint") out, err := commandOutput("zfs", "list", "-Hp", "-o", "name,used,avail,mountpoint")
if err != nil { if err != nil {
return nil, fmt.Errorf("zfs list: %w", err) return nil, fmt.Errorf("zfs list: %w", err)

View File

@@ -13,10 +13,7 @@ import (
"strings" "strings"
) )
var ( var procZfsPath = "/proc/spl/kstat/zfs"
procZfsPath = "/proc/spl/kstat/zfs"
devZfsPath = "/dev/zfs"
)
func ARCSize() (uint64, error) { func ARCSize() (uint64, error) {
file, err := os.Open(filepath.Join(procZfsPath, "arcstats")) file, err := os.Open(filepath.Join(procZfsPath, "arcstats"))
@@ -43,19 +40,6 @@ func ARCSize() (uint64, error) {
return 0, fmt.Errorf("size field not found in arcstats") return 0, fmt.Errorf("size field not found in arcstats")
} }
// checkZfsDevice lets containers without /dev/zfs fail fast instead of
// waiting for ZFS utility commands to time out.
func checkZfsDevice() error {
_, err := os.Stat(devZfsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNoZfs
}
return err
}
return nil
}
// PoolKernelStats reads pool state and cumulative I/O counters directly from // PoolKernelStats reads pool state and cumulative I/O counters directly from
// procfs. These kstats are the same interfaces used by node_exporter's Linux // procfs. These kstats are the same interfaces used by node_exporter's Linux
// ZFS collector and avoid keeping a `zpool iostat` subprocess alive. // ZFS collector and avoid keeping a `zpool iostat` subprocess alive.

View File

@@ -88,66 +88,3 @@ func TestReadObjsetIORequiresAllCounters(t *testing.T) {
_, _, err := readObjsetIO(path) _, _, err := readObjsetIO(path)
require.Error(t, err) require.Error(t, err)
} }
func TestCollectorsSkipCommandsWhenDevZfsMissing(t *testing.T) {
root := t.TempDir()
oldDevZfsPath := devZfsPath
devZfsPath = filepath.Join(root, "missing")
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
t.Fatalf("unexpected %s call with %v", name, args)
return nil, nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
_, err := PoolStats()
assert.ErrorIs(t, err, ErrNoZfs)
_, err = Datasets()
assert.ErrorIs(t, err, ErrNoZfs)
}
func TestDatasetsDelegatesWhenDevZfsPresent(t *testing.T) {
oldDevZfsPath := devZfsPath
devZfsPath = filepath.Join(t.TempDir(), "zfs")
require.NoError(t, os.WriteFile(devZfsPath, nil, 0o644))
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
assert.Equal(t, "zfs", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,used,avail,mountpoint"}, args)
return []byte("tank\t50\t50\t/tank\n"), nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
datasets, err := Datasets()
require.NoError(t, err)
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
}
func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
root := t.TempDir()
devFile := filepath.Join(root, "zfs")
require.NoError(t, os.WriteFile(devFile, []byte(""), 0o644))
oldDevZfsPath := devZfsPath
devZfsPath = devFile
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
oldCommandOutput := commandOutput
called := false
commandOutput = func(name string, args ...string) ([]byte, error) {
called = true
assert.Equal(t, "zpool", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
}

View File

@@ -1,9 +0,0 @@
//go:build !linux
package zfs
// The /dev/zfs probe is Linux-specific. Other platforms detect availability
// through the ZFS utilities themselves.
func checkZfsDevice() error {
return nil
}

View File

@@ -1,33 +0,0 @@
//go:build testing && !linux
package zfs
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
switch name {
case "zpool":
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
case "zfs":
return []byte("tank\t50\t50\t/tank\n"), nil
default:
t.Fatalf("unexpected command %s", name)
return nil, nil
}
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
datasets, err := Datasets()
require.NoError(t, err)
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
}

320
agent/zfs_pool.go Normal file
View File

@@ -0,0 +1,320 @@
package agent
import (
"log/slog"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
zfsentity "github.com/henrygd/beszel/internal/entities/zfs"
)
// zfsDatasetUsage holds usage values for a ZFS dataset mountpoint.
type zfsDatasetUsage struct {
used uint64
avail uint64
}
// datasetUsageRefreshInterval controls how often `zfs list` is re-run for the
// mountpoint usage map. Dataset inventory changes rarely.
const datasetUsageRefreshInterval = 5 * time.Minute
// poolStatsRefreshInterval controls how often `zpool list` is re-run for pool
// capacity. Health and I/O are read from procfs on Linux, so the utility only
// needs to refresh slow-moving space accounting.
const poolStatsRefreshInterval = time.Minute
type poolKernelSample struct {
nread uint64
nwrite uint64
at time.Time
}
// ZfsManager collects ZFS pool and dataset statistics. Collection functions
// are fields so unit tests can substitute them (same pattern as
// diskDiscovery.usageFn). It is safe for concurrent use by a single goroutine
// only; callers must hold the agent lock like updateDiskUsage does.
type ZfsManager struct {
poolStatsFn func() ([]zfs.PoolStat, error) // capacity/health source
datasetsFn func() ([]zfs.Dataset, error) // dataset inventory source
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
kernelSamples map[string]poolKernelSample
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
// an interval. Accessed from handler goroutines, so it is mutex-protected.
detailMu sync.Mutex
detail *zfsentity.ZfsData
lastDetailRefresh time.Time
detailInterval time.Duration
}
// newZfsManager creates a ZfsManager wired to the system's ZFS utilities.
func newZfsManager() *ZfsManager {
return &ZfsManager{
poolStatsFn: zfs.PoolStats,
datasetsFn: zfs.Datasets,
kernelStatsFn: zfs.PoolKernelStats,
poolStatusesFn: zfs.PoolStatuses,
detailInterval: time.Hour,
}
}
// Update refreshes systemStats.ZfsPools with the latest pool data. I/O
// throughput and health come from inexpensive kernel kstats on Linux. Pool
// capacity and dataset usage come from separately cached utility calls. It is
// a no-op when ZFS is absent.
func (zm *ZfsManager) Update(systemStats *system.Stats) {
pools := zm.poolStats()
if len(pools) == 0 {
return
}
kernelStats, ioRates := zm.kernelStats()
if systemStats.ZfsPools == nil {
systemStats.ZfsPools = make(map[string]*system.ZfsPool, len(pools))
}
for i := range pools {
pool := &pools[i]
// Full precision, matching the dataset values below; the frontend
// formats any magnitude.
stats := &system.ZfsPool{
Total: float64(pool.Size) / (1024 * 1024 * 1024),
Used: float64(pool.Alloc) / (1024 * 1024 * 1024),
Health: pool.Health,
}
if kernel, exists := kernelStats[pool.Name]; exists && kernel.Health != "" {
stats.Health = kernel.Health
}
if io, exists := ioRates[pool.Name]; exists {
stats.ReadBytes = io.NRead
stats.WriteBytes = io.NWrite
}
slog.Debug("ZFS pool sample", "pool", pool.Name, "health", stats.Health, "used_gb", stats.Used, "read_bps", stats.ReadBytes, "write_bps", stats.WriteBytes)
systemStats.ZfsPools[pool.Name] = stats
}
}
// poolStats returns the cached pool inventory, re-running `zpool list` at most
// every poolStatsRefreshInterval. On failure the previous inventory is
// retained and the refresh is retried on the next cadence.
func (zm *ZfsManager) poolStats() []zfs.PoolStat {
if zm.lastPoolStats.IsZero() || time.Since(zm.lastPoolStats) >= poolStatsRefreshInterval {
pools, err := zm.poolStatsFn()
if err != nil {
slog.Debug("ZFS pool stats unavailable", "err", err)
} else {
zm.poolData = pools
}
zm.lastPoolStats = time.Now()
}
return zm.poolData
}
// kernelStats reads cumulative pool counters and converts them to per-second
// rates. Counter decreases indicate a pool export/import and reset the
// baseline instead of producing an underflow spike.
func (zm *ZfsManager) kernelStats() (map[string]zfs.PoolKernelStat, map[string]zfs.PoolIoStats) {
if zm.kernelStatsFn == nil {
return nil, nil
}
stats, err := zm.kernelStatsFn()
if err != nil {
slog.Debug("ZFS kernel stats unavailable", "err", err)
return nil, nil
}
now := time.Now()
byName := make(map[string]zfs.PoolKernelStat, len(stats))
rates := make(map[string]zfs.PoolIoStats, len(stats))
nextSamples := make(map[string]poolKernelSample, len(stats))
for _, stat := range stats {
byName[stat.Name] = stat
if previous, ok := zm.kernelSamples[stat.Name]; ok && now.After(previous.at) &&
stat.NRead >= previous.nread && stat.NWrite >= previous.nwrite {
seconds := now.Sub(previous.at).Seconds()
rates[stat.Name] = zfs.PoolIoStats{
NRead: uint64(float64(stat.NRead-previous.nread) / seconds),
NWrite: uint64(float64(stat.NWrite-previous.nwrite) / seconds),
}
}
nextSamples[stat.Name] = poolKernelSample{nread: stat.NRead, nwrite: stat.NWrite, at: now}
}
zm.kernelSamples = nextSamples
return byName, rates
}
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
// and rebuilds the mountpoint-keyed usage map.
func (zm *ZfsManager) refreshDatasetUsage() {
if !zm.lastUsageRefresh.IsZero() && time.Since(zm.lastUsageRefresh) < datasetUsageRefreshInterval {
return
}
datasets, err := zm.datasetsFn()
if err != nil {
slog.Debug("ZFS dataset usage unavailable", "err", err)
} else {
usage := make(map[string]zfsDatasetUsage, len(datasets))
for _, ds := range datasets {
if ds.Mountpoint != "" && ds.Mountpoint != "-" {
usage[ds.Mountpoint] = zfsDatasetUsage{used: ds.Used, avail: ds.Avail}
}
}
zm.datasetUsage = usage
}
zm.lastUsageRefresh = time.Now()
}
// DatasetUsage returns ZFS dataset usage keyed by mountpoint, refreshed at
// most every datasetUsageRefreshInterval. On failure the previous map is
// retained and a debug log is emitted.
func (zm *ZfsManager) DatasetUsage() map[string]zfsDatasetUsage {
zm.refreshDatasetUsage()
return zm.datasetUsage
}
// GetDetail returns ZFS detail data (pool health, scrub, vdevs, datasets).
// Scheduled requests use the cached snapshot until stale; manual requests can
// force collection. On failure the previous snapshot is retained.
func (zm *ZfsManager) GetDetail(force bool) *zfsentity.ZfsData {
zm.detailMu.Lock()
defer zm.detailMu.Unlock()
if force || zm.detail == nil || time.Since(zm.lastDetailRefresh) >= zm.detailInterval {
if data, err := zm.collectDetail(zm.detail); err != nil {
slog.Debug("ZFS detail collection failed", "err", err)
if zm.detail == nil {
return &zfsentity.ZfsData{}
}
return &zfsentity.ZfsData{Pools: zm.detail.Pools}
} else {
zm.detail = data
zm.lastDetailRefresh = time.Now()
}
}
if zm.detail == nil {
return &zfsentity.ZfsData{}
}
return zm.detail
}
// collectDetail builds a ZfsData payload from the current system state.
func (zm *ZfsManager) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.ZfsData, error) {
pools, err := zm.poolStatsFn()
if err != nil {
return nil, err
}
if len(pools) == 0 {
return &zfsentity.ZfsData{Pools: []*zfsentity.PoolDetail{}, Complete: true}, nil
}
statuses, statusErr := zm.poolStatusesFn()
if statusErr != nil {
slog.Debug("ZFS pool status unavailable", "err", statusErr)
}
datasets, datasetsErr := zm.datasetsFn()
if datasetsErr != nil {
slog.Debug("ZFS datasets unavailable", "err", datasetsErr)
}
statusByPool := make(map[string]zfs.PoolStatus, len(statuses))
for _, st := range statuses {
statusByPool[st.Name] = st
}
previousByPool := make(map[string]*zfsentity.PoolDetail)
if previous != nil {
for _, pool := range previous.Pools {
if pool != nil {
previousByPool[pool.Name] = pool
}
}
}
data := &zfsentity.ZfsData{Pools: make([]*zfsentity.PoolDetail, 0, len(pools)), Complete: true}
for i := range pools {
p := &pools[i]
detail := &zfsentity.PoolDetail{
Name: p.Name,
Health: p.Health,
Size: p.Size,
Alloc: p.Alloc,
Free: p.Free,
}
if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
if st.Scrub.State != "" && st.Scrub.State != "NONE" {
detail.Scrub = &zfsentity.Scrub{
State: st.Scrub.State,
Progress: st.Scrub.Progress,
Errors: st.Scrub.Errors,
}
}
for _, v := range st.Vdevs {
detail.Vdevs = append(detail.Vdevs, &zfsentity.Vdev{
Name: v.Name,
State: v.State,
ReadErrs: v.ReadErrs,
WriteErrs: v.WriteErrs,
ChecksumErrs: v.ChecksumErrs,
})
}
} else {
if cached := previousByPool[p.Name]; cached != nil {
detail.Scrub = cached.Scrub
detail.Vdevs = cached.Vdevs
}
}
if datasetsErr == nil {
foundDataset := false
for _, ds := range datasets {
if poolOfDataset(ds.Name) == p.Name {
foundDataset = true
detail.Datasets = append(detail.Datasets, &zfsentity.Dataset{
Name: ds.Name,
Used: ds.Used,
Avail: ds.Avail,
Mountpoint: ds.Mountpoint,
})
}
}
if !foundDataset {
if cached := previousByPool[p.Name]; cached != nil {
detail.Datasets = cached.Datasets
}
}
} else if cached := previousByPool[p.Name]; cached != nil {
detail.Datasets = cached.Datasets
}
data.Pools = append(data.Pools, detail)
}
return data, nil
}
// poolOfDataset returns the pool name for a dataset name (everything before
// the first '/'). Datasets without a separator belong to a pool of the same
// name.
func poolOfDataset(name string) string {
if idx := strings.IndexByte(name, '/'); idx >= 0 {
return name[:idx]
}
return name
}
// ZfsMountpoints returns the set of mountpoints backed by ZFS datasets.
func (zm *ZfsManager) ZfsMountpoints() map[string]bool {
usage := zm.DatasetUsage()
mountpoints := make(map[string]bool, len(usage))
for mountpoint := range usage {
mountpoints[mountpoint] = true
}
return mountpoints
}

245
agent/zfs_pool_test.go Normal file
View File

@@ -0,0 +1,245 @@
//go:build testing
package agent
import (
"testing"
"time"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdatePopulatesZfsPools(t *testing.T) {
zm := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{
{Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
{Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
// Small zvol (Proxmox VM EFI disk): must not round to zero.
{Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
}, nil
}
var kernelCalls int
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
kernelCalls++
return []zfs.PoolKernelStat{{
Name: "tank", Health: "ONLINE",
NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
}}, nil
}
var stats system.Stats
// The first kernel sample establishes the cumulative-counter baseline.
zm.Update(&stats)
zm.kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
zm.Update(&stats)
require.NotNil(t, stats.ZfsPools)
require.Contains(t, stats.ZfsPools, "tank")
assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
}
// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
// I/O instead of erroring.
func TestUpdateKernelStatsMissing(t *testing.T) {
zm := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
return nil, zfs.ErrNoZfs
}
var stats system.Stats
zm.Update(&stats)
require.NotNil(t, stats.ZfsPools)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
}
func TestUpdateKernelCounterReset(t *testing.T) {
zm := &ZfsManager{}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
zm.kernelSamples = map[string]poolKernelSample{
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
}
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
}
var stats system.Stats
zm.Update(&stats)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
}
func TestUpdateNoZfs(t *testing.T) {
zm := &ZfsManager{}
calls := 0
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
calls++
return nil, zfs.ErrNoZfs
}
var stats system.Stats
zm.Update(&stats)
zm.Update(&stats)
assert.Nil(t, stats.ZfsPools)
assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
}
func TestUpdateEmptyPools(t *testing.T) {
zm := &ZfsManager{}
calls := 0
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
calls++
return nil, nil
}
var stats system.Stats
zm.Update(&stats)
zm.Update(&stats)
assert.Nil(t, stats.ZfsPools)
assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
}
func TestDatasetUsage(t *testing.T) {
zm := &ZfsManager{}
calls := 0
zm.datasetsFn = func() ([]zfs.Dataset, error) {
calls++
return []zfs.Dataset{
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
{Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
{Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
}, nil
}
usage := zm.DatasetUsage()
require.Len(t, usage, 2)
assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
assert.Equal(t, 1, calls)
// Second call within the refresh window must not re-run the collector.
zm.DatasetUsage()
assert.Equal(t, 1, calls)
}
func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
zm := &ZfsManager{}
zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
}
assert.Len(t, zm.DatasetUsage(), 1)
// Force refresh window expiry, then a failing collector.
zm.lastUsageRefresh = time.Now().Add(-10 * time.Minute)
zm.datasetsFn = func() ([]zfs.Dataset, error) {
return nil, zfs.ErrNoZfs
}
usage := zm.DatasetUsage()
assert.Len(t, usage, 1, "previous usage should be retained on error")
}
func TestGetDetailForceRefresh(t *testing.T) {
zm := &ZfsManager{detailInterval: time.Hour}
poolCalls := 0
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
poolCalls++
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
first := zm.GetDetail(false)
assert.True(t, first.Complete)
require.Len(t, first.Pools, 1)
assert.Equal(t, uint64(1), first.Pools[0].Alloc)
cached := zm.GetDetail(false)
require.Len(t, cached.Pools, 1)
assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
assert.Equal(t, 1, poolCalls)
refreshed := zm.GetDetail(true)
assert.True(t, refreshed.Complete)
require.Len(t, refreshed.Pools, 1)
assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
assert.Equal(t, 2, poolCalls)
}
func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
zm := &ZfsManager{detailInterval: time.Hour}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
require.Len(t, zm.GetDetail(false).Pools, 1)
zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
empty := zm.GetDetail(true)
assert.True(t, empty.Complete)
assert.Empty(t, empty.Pools)
}
func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
zm := &ZfsManager{detailInterval: time.Hour}
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) {
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
}
zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank/data"}}, nil
}
first := zm.GetDetail(false)
require.True(t, first.Complete)
require.Len(t, first.Pools[0].Vdevs, 1)
require.Len(t, first.Pools[0].Datasets, 1)
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
partial := zm.GetDetail(true)
require.True(t, partial.Complete)
require.Len(t, partial.Pools[0].Vdevs, 1)
require.Len(t, partial.Pools[0].Datasets, 1)
zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
lastSuccessfulRefresh := zm.lastDetailRefresh
failed := zm.GetDetail(true)
assert.False(t, failed.Complete)
require.Len(t, failed.Pools, 1)
assert.Equal(t, "tank", failed.Pools[0].Name)
assert.Equal(t, lastSuccessfulRefresh, zm.lastDetailRefresh)
}
func TestZfsMountpoints(t *testing.T) {
zm := &ZfsManager{}
zm.datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{
{Name: "tank", Mountpoint: "/tank"},
{Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
}, nil
}
mountpoints := zm.ZfsMountpoints()
assert.Len(t, mountpoints, 2)
assert.True(t, mountpoints["/tank"])
assert.True(t, mountpoints["/"])
}

6
go.mod
View File

@@ -5,13 +5,12 @@ go 1.27.1
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0 github.com/coreos/go-systemd/v22 v22.7.0
github.com/distribution/reference v0.6.0
github.com/ebitengine/purego v0.11.0 github.com/ebitengine/purego v0.11.0
github.com/fxamacker/cbor/v2 v2.9.3 github.com/fxamacker/cbor/v2 v2.9.3
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.10.1 github.com/lxzan/gws v1.10.1
github.com/nicholas-fedor/shoutrrr v0.20.0 github.com/nicholas-fedor/shoutrrr v0.19.0
github.com/opencontainers/go-digest v1.0.0
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.40.2 github.com/pocketbase/pocketbase v0.40.2
github.com/shirou/gopsutil/v4 v4.26.8 github.com/shirou/gopsutil/v4 v4.26.8
@@ -42,7 +41,6 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/compress v1.20.0 // indirect

12
go.sum
View File

@@ -15,8 +15,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -56,8 +54,8 @@ 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/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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY= github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg=
github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -85,14 +83,12 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac= github.com/nicholas-fedor/shoutrrr v0.19.0 h1:Rl6bpK3DXuR2Trtx2JV8t+wjUwkHdRHrc8nBKoEpHr0=
github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA= github.com/nicholas-fedor/shoutrrr v0.19.0/go.mod h1:Glfdi8AGTbnEn2k2+hW62n8oL0i9vqRVFtXaUIthNks=
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=

View File

@@ -20,10 +20,10 @@ type hubLike interface {
} }
type AlertManager struct { type AlertManager struct {
hub hubLike hub hubLike
stopOnce sync.Once stopOnce sync.Once
pendingAlerts sync.Map pendingAlerts sync.Map
alertsCache *AlertsCache alertsCache *AlertsCache
} }
type AlertMessageData struct { type AlertMessageData struct {
@@ -66,7 +66,6 @@ type SystemAlertGPUData struct {
} }
type SystemAlertZfsPool struct { type SystemAlertZfsPool struct {
Raw bool `json:"raw,omitempty"`
Total float64 `json:"d"` Total float64 `json:"d"`
Used float64 `json:"du"` Used float64 `json:"du"`
} }
@@ -232,20 +231,8 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err) am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
} }
// send alerts via webhooks // send alerts via webhooks
send := sendPublicNotification
if len(userAlertSettings.Webhooks) > 0 {
// Read the owner's current role at delivery time, including for URLs
// saved before an admin was demoted. Never fall back on lookup failure.
owner, err := am.hub.FindRecordById("users", data.UserID)
if err != nil {
return fmt.Errorf("load notification owner: %w", err)
}
if owner.GetString("role") == "admin" {
send = shoutrrr.Send
}
}
for _, webhook := range userAlertSettings.Webhooks { for _, webhook := range userAlertSettings.Webhooks {
if err := am.sendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText, send); err != nil { if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err) am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
} }
} }
@@ -276,10 +263,6 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
// SendShoutrrrAlert sends an alert via a Shoutrrr URL // SendShoutrrrAlert sends an alert via a Shoutrrr URL
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error { func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
return am.sendShoutrrrAlert(notificationUrl, title, message, link, linkText, shoutrrr.Send)
}
func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link, linkText string, send func(string, string) error) error {
// Parse the URL // Parse the URL
parsedURL, err := url.Parse(notificationUrl) parsedURL, err := url.Parse(notificationUrl)
if err != nil { if err != nil {
@@ -322,7 +305,7 @@ func (am *AlertManager) sendShoutrrrAlert(notificationUrl, title, message, link,
parsedURL.RawQuery = queryParams.Encode() parsedURL.RawQuery = queryParams.Encode()
// log.Println("URL after modification:", parsedURL.String()) // log.Println("URL after modification:", parsedURL.String())
err = send(parsedURL.String(), message) err = shoutrrr.Send(parsedURL.String(), message)
if err == nil { if err == nil {
am.hub.Logger().Info("Sent shoutrrr alert", "title", title) am.hub.Logger().Info("Sent shoutrrr alert", "title", title)

View File

@@ -3,11 +3,13 @@ package alerts
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"net"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strings"
"github.com/henrygd/beszel/internal/hub/utils" "github.com/henrygd/beszel/internal/hub/utils"
"github.com/nicholas-fedor/shoutrrr"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -145,16 +147,72 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
if err != nil || data.URL == "" { if err != nil || data.URL == "" {
return e.BadRequestError("URL is required", err) return e.BadRequestError("URL is required", err)
} }
send := shoutrrr.Send // Only allow admins to send test notifications to internal URLs
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" { if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
send = sendPublicNotification internalURL, err := isInternalURL(data.URL)
} if err != nil {
err = am.sendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel", send) return e.BadRequestError(err.Error(), nil)
if errors.Is(err, errInternalDestination) || errors.Is(err, errUnrestrictedService) { }
return e.ForbiddenError(err.Error(), nil) if internalURL {
return e.ForbiddenError("Only admins can send to internal destinations", nil)
}
} }
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil { if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()}) return e.JSON(200, map[string]string{"err": err.Error()})
} }
return e.JSON(200, map[string]bool{"err": false}) return e.JSON(200, map[string]bool{"err": false})
} }
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
func isInternalURL(rawURL string) (bool, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return false, err
}
host := parsedURL.Hostname()
if host == "" {
return false, nil
}
if strings.EqualFold(host, "localhost") {
return true, nil
}
if ip := net.ParseIP(host); ip != nil {
return isInternalIP(ip), nil
}
// Some Shoutrrr URLs use the host position for service identifiers rather than a
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
// to names that look like actual hostnames so valid service URLs keep working.
if !strings.Contains(host, ".") {
return false, nil
}
ips, err := net.LookupIP(host)
if err != nil {
return false, nil
}
if slices.ContainsFunc(ips, isInternalIP) {
return true, nil
}
return false, nil
}
var cgnatNetwork = &net.IPNet{
IP: net.IPv4(100, 64, 0, 0),
Mask: net.CIDRMask(10, 32),
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsMulticast() ||
cgnatNetwork.Contains(ip)
}

View File

@@ -7,11 +7,10 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"sync/atomic"
"testing" "testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests" beszelTests "github.com/henrygd/beszel/internal/tests"
pbTests "github.com/pocketbase/pocketbase/tests" pbTests "github.com/pocketbase/pocketbase/tests"
@@ -30,6 +29,43 @@ func jsonReader(v any) io.Reader {
return bytes.NewReader(data) return bytes.NewReader(data)
} }
func TestIsInternalURL(t *testing.T) {
testCases := []struct {
name string
url string
internal bool
}{
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
{name: "private ipv4", url: "generic://10.0.0.1", internal: true},
{name: "localhost hostname", url: "generic://localhost", internal: true},
{name: "localhost with path", url: "generic+http://localhost/api/v1/postStuff", internal: true},
{name: "loopback with port and path", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
{name: "public hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
{name: "cloud metadata ipv4", url: "generic://169.254.169.254", internal: true},
{name: "link-local ipv4", url: "generic://169.254.1.1", internal: true},
{name: "link-local ipv6", url: "generic://[fe80::1]", internal: true},
{name: "mapped link-local ipv4", url: "generic://[::ffff:169.254.169.254]", internal: true},
{name: "cgnat lower boundary", url: "generic://100.64.0.0", internal: true},
{name: "cgnat upper boundary", url: "generic://100.127.255.255", internal: true},
{name: "below cgnat", url: "generic://100.63.255.255", internal: false},
{name: "above cgnat", url: "generic://100.128.0.0", internal: false},
{name: "multicast ipv4", url: "generic://224.0.0.1", internal: true},
{name: "multicast ipv6", url: "generic://[ff02::1]", internal: true},
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
{name: "public ipv6", url: "generic://[2001:4860:4860::8888]", internal: false},
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
{name: "single label service url", url: "slack://token@team/channel", internal: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
internal, err := alerts.IsInternalURL(testCase.url)
assert.NoError(t, err)
assert.Equal(t, testCase.internal, internal)
})
}
}
func TestUserAlertsApi(t *testing.T) { func TestUserAlertsApi(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir()) hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup() defer hub.Cleanup()
@@ -421,17 +457,6 @@ func TestSendTestNotification(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t) hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
var delivered atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
delivered.Add(1)
}))
defer server.Close()
localURL := "generic+" + server.URL
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
assert.NoError(t, err)
readonlyToken, err := readonlyUser.NewAuthToken()
assert.NoError(t, err)
userToken, err := user.NewAuthToken() userToken, err := user.NewAuthToken()
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin") adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
@@ -456,11 +481,11 @@ func TestSendTestNotification(t *testing.T) {
ExpectedContent: []string{"requires valid"}, ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": localURL, "url": "generic://127.0.0.1",
}), }),
}, },
{ {
Name: "POST /test-notification - invalid service reports error", Name: "POST /test-notification - with external auth should succeed",
Method: http.MethodPost, Method: http.MethodPost,
URL: "/api/beszel/test-notification", URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory, TestAppFactory: testAppFactory,
@@ -468,7 +493,7 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": userToken, "Authorization": userToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": "unknown://example.com", "url": "generic://8.8.8.8",
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"}, ExpectedContent: []string{"\"err\":"},
@@ -510,10 +535,10 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": adminUserToken, "Authorization": adminUserToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": localURL, "url": "generic://127.0.0.1",
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":false"}, ExpectedContent: []string{"\"err\":"},
}, },
{ {
Name: "POST /test-notification - internal url with superuser auth should succeed", Name: "POST /test-notification - internal url with superuser auth should succeed",
@@ -524,28 +549,14 @@ func TestSendTestNotification(t *testing.T) {
"Authorization": superuserToken, "Authorization": superuserToken,
}, },
Body: jsonReader(map[string]any{ Body: jsonReader(map[string]any{
"url": localURL, "url": "generic://127.0.0.1",
}), }),
ExpectedStatus: 200, ExpectedStatus: 200,
ExpectedContent: []string{"\"err\":"}, ExpectedContent: []string{"\"err\":"},
}, },
} }
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
scenarios = append(scenarios, beszelTests.ApiScenario{
Name: "readonly cannot send to " + url,
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{"Authorization": readonlyToken},
Body: jsonReader(map[string]any{"url": url}),
ExpectedStatus: 403,
ExpectedContent: []string{"Only admins"},
})
}
for _, scenario := range scenarios { for _, scenario := range scenarios {
scenario.Test(t) scenario.Test(t)
} }
assert.EqualValues(t, 2, delivered.Load(), "only admin and superuser requests should reach the server")
} }

View File

@@ -78,7 +78,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
} }
for _, pool := range data.Stats.ZfsPools { for _, pool := range data.Stats.ZfsPools {
if pool != nil && !pool.Raw && pool.Total > 0 { if pool != nil && pool.Total > 0 {
usedPct := pool.Used / pool.Total * 100 usedPct := pool.Used / pool.Total * 100
if usedPct > maxUsedPct { if usedPct > maxUsedPct {
maxUsedPct = usedPct maxUsedPct = usedPct
@@ -256,7 +256,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
} }
// add zfs pool usage from historical record // add zfs pool usage from historical record
for key, pool := range stats.ZfsPools { for key, pool := range stats.ZfsPools {
if !pool.Raw && pool.Total > 0 { if pool.Total > 0 {
zfsKey := zfsDiskAlertKey(key) zfsKey := zfsDiskAlertKey(key)
if _, ok := alert.mapSums[zfsKey]; !ok { if _, ok := alert.mapSums[zfsKey]; !ok {
alert.mapSums[zfsKey] = 0.0 alert.mapSums[zfsKey] = 0.0
@@ -319,11 +319,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
if sumPct > maxPct { if sumPct > maxPct {
maxPct = sumPct maxPct = sumPct
alert.descriptor = diskAlertDescriptor(key) alert.descriptor = diskAlertDescriptor(key)
if poolKey, ok := strings.CutPrefix(key, "zfs:"); ok {
if pool := data.Stats.ZfsPools[poolKey]; pool != nil && pool.DisplayName != "" {
alert.descriptor = diskAlertDescriptor(zfsDiskAlertKey(pool.DisplayName))
}
}
} }
} }
alert.val = float64(maxPct / float32(alert.count)) alert.val = float64(maxPct / float32(alert.count))
@@ -375,7 +370,7 @@ func zfsDiskAlertKey(poolName string) string {
func diskAlertDescriptor(key string) string { func diskAlertDescriptor(key string) string {
if poolName, ok := strings.CutPrefix(key, "zfs:"); ok { if poolName, ok := strings.CutPrefix(key, "zfs:"); ok {
return fmt.Sprintf("Usage of storage pool %s", poolName) return fmt.Sprintf("Usage of ZFS pool %s", poolName)
} }
return fmt.Sprintf("Usage of %s", key) return fmt.Sprintf("Usage of %s", key)
} }

View File

@@ -100,6 +100,10 @@ func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool)
return am.setAlertTriggered(alert, triggered) return am.setAlertTriggered(alert, triggered)
} }
func IsInternalURL(rawURL string) (bool, error) {
return isInternalURL(rawURL)
}
// BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing. // BuildContainerLogExcerpt exposes buildContainerLogExcerpt for testing.
func BuildContainerLogExcerpt(raw string) string { func BuildContainerLogExcerpt(raw string) string {
return buildContainerLogExcerpt(raw) return buildContainerLogExcerpt(raw)

View File

@@ -1,66 +0,0 @@
//go:build testing
package alerts_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/require"
)
func TestPersistedWebhooksUseCurrentOwnerRole(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
am := alerts.NewTestAlertManagerWithoutWorker(hub)
var delivered atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
delivered.Add(1)
}))
defer server.Close()
settings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
require.NoError(t, err)
settings.Set("settings", alerts.UserNotificationSettings{Webhooks: []string{"generic+" + server.URL}})
require.NoError(t, hub.Save(settings))
message := alerts.AlertMessageData{UserID: user.Id, Title: "Test", Message: "Persisted webhook"}
// Keep the same URL and manager while changing roles, so cached privileges
// or treating previously saved URLs as trusted would fail this test.
for _, tc := range []struct {
name string
role string
want int32
}{
{"regular user", "user", 0},
{"readonly user", "readonly", 0},
{"promoted admin", "admin", 1},
{"demoted admin", "user", 1},
} {
t.Run(tc.name, func(t *testing.T) {
user.Set("role", tc.role)
require.NoError(t, hub.Save(user))
// Webhook errors are logged; SendAlert continues to email delivery.
require.NoError(t, am.SendAlert(message))
require.Equal(t, tc.want, delivered.Load())
})
}
t.Run("missing owner fails closed", func(t *testing.T) {
// Model an orphaned settings record without deleting it through the
// normal user deletion cascade.
const missingOwner = "missingowner123"
settings.Set("user", missingOwner)
require.NoError(t, hub.SaveNoValidate(settings))
message.UserID = missingOwner
err := am.SendAlert(message)
require.ErrorContains(t, err, "load notification owner")
require.EqualValues(t, 1, delivered.Load())
})
}

View File

@@ -46,15 +46,12 @@ func (am *AlertManager) handleZfsPoolHealthAlert(e *core.RecordEvent, oldHealth
} }
systemName := systemRecord.GetString("name") systemName := systemRecord.GetString("name")
poolName := e.Record.GetString("display_name") poolName := e.Record.GetString("name")
if poolName == "" {
poolName = e.Record.GetString("name")
}
title := fmt.Sprintf("Storage pool %s on %s: %s", newHealth, systemName, poolName) title := fmt.Sprintf("ZFS pool %s on %s: %s", newHealth, systemName, poolName)
message := fmt.Sprintf("Storage pool %s (%s) was first observed as %s", poolName, systemName, newHealth) message := fmt.Sprintf("ZFS pool %s (%s) was first observed as %s", poolName, systemName, newHealth)
if oldSeverity > 0 { if oldSeverity > 0 {
message = fmt.Sprintf("Storage pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth) message = fmt.Sprintf("ZFS pool %s (%s) health changed from %s to %s", poolName, systemName, oldHealth, newHealth)
} }
userIDs := systemRecord.GetStringSlice("users") userIDs := systemRecord.GetStringSlice("users")
@@ -119,7 +116,7 @@ func createZfsPoolHistoryRecord(app core.App, userID, systemID, alertID, poolNam
record.Set("user", userID) record.Set("user", userID)
record.Set("system", systemID) record.Set("system", systemID)
record.Set("alert_id", alertID) record.Set("alert_id", alertID)
record.Set("name", "Storage Pool: "+poolName) record.Set("name", "ZFS Pool: "+poolName)
return app.Save(record) return app.Save(record)
} }

View File

@@ -143,37 +143,3 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
assert.False(t, diskAlert.GetBool("triggered"), assert.False(t, diskAlert.GetBool("triggered"),
"Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)") "Alert should be resolved when ZFS pool average (50%%) drops below threshold (80%%)")
} }
func TestDiskAlertIgnoresRawPool(t *testing.T) {
for _, minutes := range []int{0, 2} {
hub, user := beszelTests.GetHubWithUser(t)
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "Disk", "system": systems[0].Id, "user": user.Id, "value": 80, "min": minutes})
require.NoError(t, err)
pools := map[string]*system.ZfsPool{"btrfs": {Total: 100, Used: 99, Raw: true}}
for _, offset := range []time.Duration{-180, -90, -60, -30} {
data, err := json.Marshal(system.Stats{ZfsPools: pools})
require.NoError(t, err)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{"system": systems[0].Id, "type": "1m", "stats": string(data)})
require.NoError(t, err)
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
require.NoError(t, hub.SaveNoValidate(record))
}
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
time.Sleep(20 * time.Millisecond)
record, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("triggered"))
if minutes > 0 {
// A current usable sample must not make raw historical values eligible.
pools["btrfs"].Raw = false
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
time.Sleep(20 * time.Millisecond)
record, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("triggered"))
}
hub.Cleanup()
}
}

View File

@@ -10,6 +10,6 @@ import (
func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) { func TestZfsDiskAlertKeyIsNamespaced(t *testing.T) {
assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank")) assert.Equal(t, "zfs:tank", zfsDiskAlertKey("tank"))
assert.Equal(t, "Usage of storage pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank"))) assert.Equal(t, "Usage of ZFS pool tank", diskAlertDescriptor(zfsDiskAlertKey("tank")))
assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank")) assert.Equal(t, "Usage of tank", diskAlertDescriptor("tank"))
} }

View File

@@ -42,7 +42,7 @@ func TestZfsPoolAlertOnlineToDegraded(t *testing.T) {
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED") assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after pool became DEGRADED")
lastMessage := hub.TestMailer.LastMessage() lastMessage := hub.TestMailer.LastMessage()
assert.Contains(t, lastMessage.Subject, "Storage pool DEGRADED on test-system") assert.Contains(t, lastMessage.Subject, "ZFS pool DEGRADED on test-system")
assert.Contains(t, lastMessage.Subject, "tank") assert.Contains(t, lastMessage.Subject, "tank")
assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED") assert.Contains(t, lastMessage.Text, "ONLINE to DEGRADED")
} }
@@ -76,7 +76,7 @@ func TestZfsPoolAlertDegradedToFaulted(t *testing.T) {
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition") assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should alert on initial DEGRADED state and later FAULTED transition")
lastMessage := hub.TestMailer.LastMessage() lastMessage := hub.TestMailer.LastMessage()
assert.Contains(t, lastMessage.Subject, "Storage pool FAULTED on test-system") assert.Contains(t, lastMessage.Subject, "ZFS pool FAULTED on test-system")
} }
func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) { func TestZfsPoolAlertNoAlertOnRecovery(t *testing.T) {
@@ -239,7 +239,7 @@ func TestZfsPoolAlertWritesHistory(t *testing.T) {
history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id}) history, err := hub.FindRecordsByFilter("alerts_history", "alert_id={:alert_id}", "", 0, 0, map[string]any{"alert_id": pool.Id})
assert.NoError(t, err) assert.NoError(t, err)
require.Len(t, history, 1, "expected one history entry per user") require.Len(t, history, 1, "expected one history entry per user")
assert.Equal(t, "Storage Pool: tank", history[0].GetString("name")) assert.Equal(t, "ZFS Pool: tank", history[0].GetString("name"))
assert.Equal(t, system.Id, history[0].GetString("system")) assert.Equal(t, system.Id, history[0].GetString("system"))
} }

View File

@@ -1,150 +0,0 @@
package alerts
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"sync/atomic"
"syscall"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/router"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var (
errInternalDestination = errors.New("Only admins can send to internal destinations")
errUnrestrictedService = errors.New("Only admins can use this notification service") // Restrict services w/o custom connection support
publicNotificationDialer = &net.Dialer{
Timeout: 10 * time.Second,
// Control checks each resolved address immediately before connecting.
Control: func(_, address string, _ syscall.RawConn) error { return checkNotificationAddress(address) },
}
publicNotificationClient = newPublicNotificationClient()
)
func newPublicNotificationClient() *http.Client {
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
// Do not use proxies: they can resolve the target themselves and
// bypass the destination check on our socket.
DialContext: publicNotificationDialer.DialContext,
TLSHandshakeTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
}
func checkNotificationAddress(address string) error {
addr, err := netip.ParseAddrPort(address)
if err != nil || addr.Addr().Zone() != "" {
return errInternalDestination
}
ip := net.IP(addr.Addr().AsSlice())
if !ip.IsGlobalUnicast() || isInternalIP(ip) {
return errInternalDestination
}
return nil
}
func sendPublicNotification(rawURL, message string) error {
client := &notificationClient{Client: publicNotificationClient}
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client, DialContext: client.dialContext})
if err == nil {
if closer, ok := service.(io.Closer); ok {
defer closer.Close()
}
err = service.Send(message, &types.Params{})
}
// Some services format errors without preserving their error chain.
if client.blocked.Load() {
return errInternalDestination
}
return err
}
type notificationClient struct {
*http.Client
blocked atomic.Bool
}
func (c *notificationClient) Do(req *http.Request) (*http.Response, error) {
response, err := c.Client.Do(req)
if errors.Is(err, errInternalDestination) {
c.blocked.Store(true)
}
return response, err
}
func (c *notificationClient) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
conn, err := publicNotificationDialer.DialContext(ctx, network, address)
if errors.Is(err, errInternalDestination) {
c.blocked.Store(true)
}
return conn, err
}
func newPublicNotificationService(rawURL string, opts types.SenderOptions) (types.Service, error) {
r := &router.ServiceRouter{}
scheme, serviceURL, err := r.ExtractServiceName(rawURL)
if err != nil {
return nil, err
}
service, err := r.NewService(scheme)
if err != nil {
return nil, err
}
httpSetter, httpOK := service.(types.HTTPClientSetter)
dialSetter, dialOK := service.(types.DialContextSetter)
if (!httpOK || opts.HTTPClient == nil) && (!dialOK || opts.DialContext == nil) {
return nil, errUnrestrictedService
}
if serviceURL.Scheme != scheme {
custom, ok := service.(types.CustomURLService)
if !ok {
return nil, fmt.Errorf("%w: %s", router.ErrCustomURLsNotSupported, scheme)
}
serviceURL, err = custom.GetServiceURLFromCustom(serviceURL)
if err != nil {
return nil, err
}
}
// Shoutrrr v0.20.0 CreateSenderWithOptions injects only AFTER Initialize.
// Matrix can log in during Initialize, so inject before it as well.
if httpOK {
httpSetter.SetHTTPClient(opts.HTTPClient)
}
if dialOK {
dialSetter.SetDialContext(opts.DialContext)
}
if err := service.Initialize(serviceURL, nil); err != nil {
return nil, err
}
// Some initializers replace their HTTP client with a default client.
if httpOK {
httpSetter.SetHTTPClient(opts.HTTPClient)
}
if dialOK {
dialSetter.SetDialContext(opts.DialContext)
}
return service, nil
}
var cgnatNetwork = &net.IPNet{
IP: net.IPv4(100, 64, 0, 0),
Mask: net.CIDRMask(10, 32),
}
func isInternalIP(ip net.IP) bool {
return ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsUnspecified() ||
ip.IsLinkLocalUnicast() ||
ip.IsMulticast() ||
cgnatNetwork.Contains(ip)
}

View File

@@ -1,217 +0,0 @@
package alerts
import (
"context"
"encoding/binary"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"golang.org/x/net/dns/dnsmessage"
)
func TestCheckNotificationAddress(t *testing.T) {
for _, host := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.64.0.0", "100.127.255.255", "0.0.0.0", "224.0.0.1", "255.255.255.255", "::1", "::", "fc00::1", "fe80::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:169.254.169.254", "fe80::1%lo", "localhost", "consul"} {
t.Run(host, func(t *testing.T) {
if err := checkNotificationAddress(net.JoinHostPort(host, "80")); !errors.Is(err, errInternalDestination) {
t.Fatalf("expected blocked address, got %v", err)
}
})
}
for _, host := range []string{"8.8.8.8", "100.63.255.255", "100.128.0.0", "2001:4860:4860::8888"} {
if err := checkNotificationAddress(net.JoinHostPort(host, "443")); err != nil {
t.Errorf("public address %s: %v", host, err)
}
}
}
func TestPublicNotificationBlocksInternalRequests(t *testing.T) {
var hits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
}))
defer server.Close()
host := strings.TrimPrefix(server.URL, "http://")
for _, rawURL := range []string{
"generic+http://" + host,
"generic+https://" + host,
"generic+http://localhost:" + strings.Split(host, ":")[1],
"matrix://user:password@" + host + "/room?disabletls=yes",
"mattermost://" + host + "/token?disabletls=yes",
} {
t.Run(rawURL, func(t *testing.T) {
if err := sendPublicNotification(rawURL, "test"); !errors.Is(err, errInternalDestination) {
t.Fatalf("expected internal destination error, got %v", err)
}
})
}
if hits.Load() != 0 {
t.Fatal("internal server received a request")
}
}
type notificationRoundTripper func(*http.Request) (*http.Response, error)
func (f notificationRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestPublicNotificationRedirect(t *testing.T) {
client := newPublicNotificationClient()
defer client.CloseIdleConnections()
transport := client.Transport
client.Transport = notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "public.example" {
return &http.Response{StatusCode: 307, Header: http.Header{"Location": {"http://127.0.0.1/"}}, Body: io.NopCloser(strings.NewReader("")), Request: r}, nil
}
return transport.RoundTrip(r)
})
_, err := client.Get("http://public.example/")
if !errors.Is(err, errInternalDestination) {
t.Fatalf("expected redirect to be blocked, got %v", err)
}
}
func TestPublicNotificationServiceClient(t *testing.T) {
for _, rawURL := range []string{"generic+http://public.example/path", "discord://token@123456789", "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook"} {
t.Run(rawURL, func(t *testing.T) {
var hits int
client := &http.Client{Transport: notificationRoundTripper(func(r *http.Request) (*http.Response, error) {
hits++
body := `{"ok":true}`
if strings.HasPrefix(rawURL, "slack:") {
body = "ok"
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
})}
service, err := newPublicNotificationService(rawURL, types.SenderOptions{HTTPClient: client})
if err != nil {
t.Fatal(err)
}
if err := service.Send("test", &types.Params{}); err != nil {
t.Fatal(err)
}
if hits == 0 {
t.Fatal("injected client was not used")
}
})
}
}
func TestPublicNotificationDNS(t *testing.T) {
// Supply deterministic DNS responses over an in-memory TCP connection.
// The first lookup sees a public IP; subsequent lookups see loopback.
var rebound atomic.Bool
resolver := &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
client, server := net.Pipe()
go func() {
defer server.Close()
var size [2]byte
if _, err := io.ReadFull(server, size[:]); err != nil {
return
}
buf := make([]byte, binary.BigEndian.Uint16(size[:]))
if _, err := io.ReadFull(server, buf); err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(buf); err != nil {
return
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
q := msg.Questions[0]
if q.Type == dnsmessage.TypeA {
ip := [4]byte{8, 8, 8, 8}
if rebound.Load() {
ip = [4]byte{127, 0, 0, 1}
}
msg.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: dnsmessage.ClassINET}, Body: &dnsmessage.AResource{A: ip}}}
}
buf, err := msg.Pack()
if err != nil {
return
}
binary.BigEndian.PutUint16(size[:], uint16(len(buf)))
server.Write(append(size[:], buf...))
}()
return client, nil
}}
// These tests do not run in parallel; restore the process resolver afterward.
previous := net.DefaultResolver
net.DefaultResolver = resolver
t.Cleanup(func() { net.DefaultResolver = previous })
ips, err := resolver.LookupIP(context.Background(), "ip4", "rebind.example")
if err != nil || len(ips) != 1 || !ips[0].Equal(net.IPv4(8, 8, 8, 8)) {
t.Fatalf("initial DNS lookup: %v, %v", ips, err)
}
rebound.Store(true)
client := newPublicNotificationClient()
defer client.CloseIdleConnections()
for _, host := range []string{"rebind.example", "consul"} {
guarded := &notificationClient{Client: client}
conn, dialErr := guarded.dialContext(context.Background(), "tcp", net.JoinHostPort(host, "25"))
if conn != nil {
conn.Close()
}
if !errors.Is(dialErr, errInternalDestination) || !guarded.blocked.Load() {
t.Errorf("expected TCP dial-time rejection for %s, got %v", host, dialErr)
}
_, err := client.Get("http://" + host + "/")
if !errors.Is(err, errInternalDestination) {
t.Errorf("expected dial-time rejection for %s, got %v", host, err)
}
}
}
func TestPublicNotificationTCP(t *testing.T) {
for _, rawURL := range []string{
"smtp://user:pass@HOST:25/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
"smtp://user:pass@HOST:465/?fromAddress=sender@example.com&toAddresses=recipient@example.com",
"mqtt://HOST:1883/topic",
"mqtts://HOST:8883/topic",
} {
t.Run(rawURL, func(t *testing.T) {
t.Parallel()
t.Run("internal destination", func(t *testing.T) {
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
if !errors.Is(err, errInternalDestination) {
t.Fatalf("expected blocked destination, got %v", err)
}
})
t.Run("public destination uses injected dialer", func(t *testing.T) {
var calls atomic.Int32
stopped := errors.New("test dial stopped")
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
calls.Add(1)
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
t.Errorf("unexpected dial: %s %s", network, address)
}
if err := checkNotificationAddress(address); err != nil {
t.Error(err)
}
return nil, stopped
},
})
if err != nil {
t.Fatal(err)
}
if closer, ok := service.(io.Closer); ok {
defer closer.Close()
}
if err := service.Send("test", &types.Params{}); err == nil {
t.Fatal("expected dial failure")
}
if calls.Load() == 0 {
t.Fatal("custom dialer was not used")
}
})
})
}
}

View File

@@ -43,11 +43,6 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
# Copy smartmontools binaries and config files # Copy smartmontools binaries and config files
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
# Install ZFS userspace utilities (zpool, zfs) for pool/dataset monitoring
RUN apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Ensure data persistence across container recreations # Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"] VOLUME ["/var/lib/beszel-agent"]

View File

@@ -65,32 +65,6 @@ RUN set -eux; \
cp -v "$interp" "/out/rootfs$interp"; \ cp -v "$interp" "/out/rootfs$interp"; \
fi fi
# --------------------------
# ZFS utilities builder stage
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*
# Copy the zpool/zfs binaries and their required runtime libraries
RUN set -eux; \
mkdir -p /out/rootfs/lib /out/rootfs/lib64 /out/rootfs/usr/lib; \
for bin in /usr/sbin/zpool /usr/sbin/zfs; do \
mkdir -p "/out/rootfs$(dirname "$bin")"; \
cp -v "$bin" "/out/rootfs$bin"; \
ldd "$bin" \
| awk '{print $3}' \
| grep '^/' \
| xargs -r -I '{}' sh -c 'mkdir -p "/out/rootfs$(dirname "{}")"; cp -v "{}" "/out/rootfs{}"'; \
interp="$(ldd "$bin" | awk "/ld-linux/ {print \$1}")"; \
if [ -n "$interp" ] && [ -e "$interp" ]; then \
mkdir -p "/out/rootfs$(dirname "$interp")"; \
cp -v "$interp" "/out/rootfs$interp"; \
fi; \
done
# -------------------------- # --------------------------
# Final image: lightweight multi-arch NVIDIA agent (slim) # Final image: lightweight multi-arch NVIDIA agent (slim)
# -------------------------- # --------------------------
@@ -104,9 +78,6 @@ COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
COPY --from=smartmontools-builder /out/rootfs/ / COPY --from=smartmontools-builder /out/rootfs/ /
# Copy ZFS utilities (zpool, zfs) binaries and required runtime libraries
COPY --from=zfsutils-builder /out/rootfs/ /
# nvidia-smi is intentionally not bundled. # nvidia-smi is intentionally not bundled.
# Mount the host binary instead, for example: # Mount the host binary instead, for example:
# - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro # - /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro

View File

@@ -186,12 +186,11 @@ type Stats struct {
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes] Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"` Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"` Status string `json:"-" cbor:"6,keyasint"`
Id string `json:"-" cbor:"7,keyasint"` Id string `json:"-" cbor:"7,keyasint"`
Image string `json:"-" cbor:"8,keyasint"` Image string `json:"-" cbor:"8,keyasint"`
Ports string `json:"-" cbor:"10,keyasint"` Ports string `json:"-" cbor:"10,keyasint"`
UpdateAvailable bool `json:"u,omitzero" cbor:"11,keyasint,omitzero"`
// PrevCpu [2]uint64 `json:"-"` // PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"` CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"` CpuContainer uint64 `json:"-"`

View File

@@ -59,15 +59,11 @@ type Stats struct {
// ZfsPool holds per-pool ZFS metrics for a single collection interval. // ZfsPool holds per-pool ZFS metrics for a single collection interval.
type ZfsPool struct { type ZfsPool struct {
DisplayName string `json:"n,omitempty" cbor:"8,keyasint,omitempty"` Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
HideUsage bool `json:"hu,omitempty" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
HideIO bool `json:"hi,omitempty" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
Raw bool `json:"raw,omitempty" cbor:"5,keyasint,omitempty"` WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
WriteBytes uint64 `json:"wb,omitzero" cbor:"3,keyasint,omitzero"` // write throughput in bytes/s
Health string `json:"h,omitempty" cbor:"4,keyasint,omitempty"` // ONLINE, DEGRADED, FAULTED, ...
} }
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.

View File

@@ -1,47 +1,23 @@
// Package zfs defines the ZFS detail data exchanged between agent and hub. // Package zfs defines the ZFS detail data exchanged between agent and hub.
package zfs package zfs
import "strings"
// ZfsData is the detail payload returned by the agent for the GetZfsData action. // ZfsData is the detail payload returned by the agent for the GetZfsData action.
type ZfsData struct { type ZfsData struct {
Pools []*PoolDetail `json:"pools,omitempty"` Pools []*PoolDetail `json:"pools,omitempty"`
Complete bool `json:"complete,omitempty"` Complete bool `json:"complete,omitempty"`
// Backends whose inventories are complete, even when another backend failed.
CompleteBackends []string `json:"completeBackends,omitempty"`
}
// CanRefreshPool also governs deletion: missing pools may only be removed
// after a successful inventory of their backend. Complete supports old agents.
func (data *ZfsData) CanRefreshPool(name string) bool {
if data.Complete {
return true
}
backend := "zfs"
if strings.HasPrefix(name, "b:") {
backend = "btrfs"
}
for _, complete := range data.CompleteBackends {
if complete == backend {
return true
}
}
return false
} }
// PoolDetail holds the verbose state of a single pool: capacity, health, // PoolDetail holds the verbose state of a single pool: capacity, health,
// scrub, vdev, and dataset information. // scrub, vdev, and dataset information.
type PoolDetail struct { type PoolDetail struct {
DisplayName string `json:"displayName,omitempty"` Name string `json:"name"`
Raw bool `json:"raw,omitempty"` Health string `json:"health,omitempty"`
Name string `json:"name"` Size uint64 `json:"size,omitempty"` // bytes
Health string `json:"health,omitempty"` Alloc uint64 `json:"alloc,omitempty"` // bytes
Size uint64 `json:"size,omitempty"` // bytes Free uint64 `json:"free,omitempty"` // bytes
Alloc uint64 `json:"alloc,omitempty"` // bytes Scrub *Scrub `json:"scrub,omitempty"`
Free uint64 `json:"free,omitempty"` // bytes Vdevs []*Vdev `json:"vdevs,omitempty"`
Scrub *Scrub `json:"scrub,omitempty"` Datasets []*Dataset `json:"datasets,omitempty"`
Vdevs []*Vdev `json:"vdevs,omitempty"`
Datasets []*Dataset `json:"datasets,omitempty"`
} }
// Scrub holds the scrub (or resilver) status of a pool. // Scrub holds the scrub (or resilver) status of a pool.

View File

@@ -2,17 +2,13 @@ package hub
import ( import (
"context" "context"
"fmt"
"log/slog"
"net"
"net/http" "net/http"
"net/netip"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"uuid"
"github.com/blang/semver" "github.com/blang/semver"
"github.com/google/uuid"
"github.com/henrygd/beszel" "github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/ghupdate" "github.com/henrygd/beszel/internal/ghupdate"
@@ -82,81 +78,12 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
} }
// authenticate with trusted header // authenticate with trusted header
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
// only honor the header from these peers, if set
trustedProxies, restricted := parseTrustedProxies()
se.Router.BindFunc(func(e *core.RequestEvent) error { se.Router.BindFunc(func(e *core.RequestEvent) error {
if restricted && !isTrustedProxy(trustedProxies, e.Request.RemoteAddr) {
return e.Next()
}
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
}) })
} }
} }
// parseTrustedProxies reads TRUSTED_PROXY_IPS (comma-separated IPs or CIDRs).
// restricted is false when the variable is unset or empty, meaning the trusted
// header is accepted from any peer. Invalid entries are skipped with a warning,
// so a typo narrows the allowlist rather than widening it.
func parseTrustedProxies() (prefixes []netip.Prefix, restricted bool) {
value, _ := utils.GetEnv("TRUSTED_PROXY_IPS")
if value == "" {
return nil, false
}
for entry := range strings.SplitSeq(value, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if prefix, err := parseProxyPrefix(entry); err == nil {
prefixes = append(prefixes, prefix)
} else {
slog.Warn("Ignoring invalid TRUSTED_PROXY_IPS entry", "entry", entry)
}
}
return prefixes, true
}
// parseProxyPrefix parses an IP or CIDR into a masked prefix. IPv4-mapped IPv6
// entries are converted to IPv4 so they match IPv4 peers.
func parseProxyPrefix(entry string) (netip.Prefix, error) {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
addr, err := netip.ParseAddr(entry)
if err != nil {
return netip.Prefix{}, err
}
addr = addr.Unmap()
return netip.PrefixFrom(addr, addr.BitLen()), nil
}
if prefix.Addr().Is4In6() {
if prefix.Bits() < 96 {
return netip.Prefix{}, fmt.Errorf("%s covers more than the IPv4-mapped range", entry)
}
prefix = netip.PrefixFrom(prefix.Addr().Unmap(), prefix.Bits()-96)
}
return prefix.Masked(), nil
}
// isTrustedProxy reports whether the peer address of a request (host:port) is
// within one of the prefixes.
func isTrustedProxy(prefixes []netip.Prefix, remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
addr = addr.Unmap().WithZone("")
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// registerApiRoutes registers custom API routes // registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes // auth protected routes

View File

@@ -6,16 +6,12 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"sort"
"testing" "testing"
"time"
beszelTests "github.com/henrygd/beszel/internal/tests" beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/migrations" "github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests" pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -30,59 +26,6 @@ func jsonReader(v any) io.Reader {
return bytes.NewReader(data) return bytes.NewReader(data)
} }
type gatedReader struct {
data []byte
started chan struct{}
release chan struct{}
offset int
}
func (r *gatedReader) Read(p []byte) (int, error) {
if r.offset == 0 {
close(r.started)
<-r.release
}
if r.offset >= len(r.data) {
return 0, io.EOF
}
n := copy(p, r.data[r.offset:])
r.offset += n
return n, nil
}
func firstUserTestMux(t *testing.T) (*beszelTests.TestHub, http.Handler) {
t.Helper()
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
_ = hub.StartHub()
router, err := apis.NewRouter(hub.TestApp)
require.NoError(t, err)
serveEvent := &core.ServeEvent{App: hub.TestApp, Router: router}
var handler http.Handler
err = hub.TestApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
var buildErr error
handler, buildErr = e.Router.BuildMux()
return buildErr
})
require.NoError(t, err)
require.NotNil(t, handler)
return hub, handler
}
func postFirstUser(handler http.Handler, email string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{
"email": email,
"password": "password123",
})
req := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func TestApiRoutesAuthentication(t *testing.T) { func TestApiRoutesAuthentication(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t) hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
@@ -846,87 +789,6 @@ func TestFirstUserCreation(t *testing.T) {
}) })
} }
func TestFirstUserBootstrapAtomicity(t *testing.T) {
t.Run("concurrent complete requests produce exactly one winner", func(t *testing.T) {
hub, handler := firstUserTestMux(t)
defer hub.Cleanup()
start := make(chan struct{})
statuses := make(chan int, 2)
for _, email := range []string{"first@example.com", "second@example.com"} {
go func(email string) {
<-start
statuses <- postFirstUser(handler, email).Code
}(email)
}
close(start)
got := []int{<-statuses, <-statuses}
sort.Ints(got)
require.Equal(t, []int{http.StatusOK, http.StatusForbidden}, got)
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, users, 1)
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, superusers, 1)
require.NotEqual(t, migrations.TempAdminEmail, superusers[0].Email())
})
t.Run("partial body cannot retain stale bootstrap authorization", func(t *testing.T) {
hub, handler := firstUserTestMux(t)
defer hub.Cleanup()
body, err := json.Marshal(map[string]string{
"email": "parked@example.com",
"password": "password123",
})
require.NoError(t, err)
gated := &gatedReader{
data: body,
started: make(chan struct{}),
release: make(chan struct{}),
}
parkedRequest := httptest.NewRequest(http.MethodPost, "/api/beszel/create-user", gated)
parkedRequest.Header.Set("Content-Type", "application/json")
parkedRecorder := httptest.NewRecorder()
parkedDone := make(chan struct{})
go func() {
handler.ServeHTTP(parkedRecorder, parkedRequest)
close(parkedDone)
}()
select {
case <-gated.started:
case <-time.After(2 * time.Second):
t.Fatal("parked request did not begin reading its body")
}
operatorRecorder := postFirstUser(handler, "operator@example.com")
require.Equal(t, http.StatusOK, operatorRecorder.Code)
lateRecorder := postFirstUser(handler, "late@example.com")
require.Equal(t, http.StatusForbidden, lateRecorder.Code)
close(gated.release)
select {
case <-parkedDone:
case <-time.After(2 * time.Second):
t.Fatal("parked request did not finish")
}
require.Equal(t, http.StatusForbidden, parkedRecorder.Code)
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.Len(t, users, 1)
require.Equal(t, "operator@example.com", users[0].Email())
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.Len(t, superusers, 1)
require.Equal(t, "operator@example.com", superusers[0].Email())
})
}
func TestCreateUserEndpointAvailability(t *testing.T) { func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir()) hub, _ := beszelTests.NewTestHub(t.TempDir())
@@ -1107,79 +969,6 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
} }
} }
func TestTrustedHeaderProxyAllowlist(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
// httptest requests arrive from 192.0.2.1:1234
testCases := []struct {
name string
proxies string
expectedStatus int
expectedContent []string
}{
{
name: "peer inside an allowed range",
proxies: "10.0.0.0/8, 192.0.2.0/24",
expectedStatus: 200,
expectedContent: []string{"\"key\":", "\"v\":"},
},
{
name: "peer is the listed address",
proxies: "192.0.2.1",
expectedStatus: 200,
expectedContent: []string{"\"key\":", "\"v\":"},
},
{
name: "peer outside the allowlist",
proxies: "10.0.0.0/8",
expectedStatus: 401,
expectedContent: []string{"requires valid"},
},
{
name: "allowlist with no valid entry",
proxies: "proxy.internal",
expectedStatus: 401,
expectedContent: []string{"requires valid"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
t.Setenv("TRUSTED_PROXY_IPS", tc.proxies)
scenario := beszelTests.ApiScenario{
Name: "GET /getkey - with trusted header",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: tc.expectedStatus,
ExpectedContent: tc.expectedContent,
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
}
scenario.Test(t)
})
}
}
func TestUpdateEndpoint(t *testing.T) { func TestUpdateEndpoint(t *testing.T) {
t.Setenv("CHECK_UPDATES", "true") t.Setenv("CHECK_UPDATES", "true")

View File

@@ -99,8 +99,8 @@ func setCollectionAuthSettings(app core.App) error {
} }
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{ if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
list: &systemScopedWriteRule, list: &systemScopedReadRule,
view: &systemScopedWriteRule, view: &systemScopedReadRule,
create: &systemScopedWriteRule, create: &systemScopedWriteRule,
update: &systemScopedWriteRule, update: &systemScopedWriteRule,
delete: &systemScopedWriteRule, delete: &systemScopedWriteRule,

View File

@@ -88,8 +88,8 @@ func TestCollectionRulesDefault(t *testing.T) {
// fingerprints collection // fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection") require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ListRule) assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ListRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.ViewRule) assert.Equal(t, isUserInSystemUsers, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule) assert.Equal(t, isUserInSystemUsersNotReadonly, *fingerprintsCollection.DeleteRule)
@@ -216,8 +216,8 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
// fingerprints collection // fingerprints collection
fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints") fingerprintsCollection, err := hub.FindCollectionByNameOrId("fingerprints")
require.NoError(t, err, "Failed to find fingerprints collection") require.NoError(t, err, "Failed to find fingerprints collection")
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ListRule) assert.Equal(t, isUser, *fingerprintsCollection.ListRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.ViewRule) assert.Equal(t, isUser, *fingerprintsCollection.ViewRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.CreateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.UpdateRule)
assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule) assert.Equal(t, isUserNotReadonly, *fingerprintsCollection.DeleteRule)

View File

@@ -6,8 +6,8 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"uuid"
"github.com/google/uuid"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"

View File

@@ -122,8 +122,6 @@ func (h *Hub) initialize(app core.App) error {
settings := app.Settings() settings := app.Settings()
// batch requests (for alerts) // batch requests (for alerts)
settings.Batch.Enabled = true settings.Batch.Enabled = true
settings.Batch.MaxRequests = 100
settings.Batch.MaxBodySize = 1 << 20 // 1 MiB
// set URL if APP_URL env is set // set URL if APP_URL env is set
if appURL, isSet := utils.GetEnv("APP_URL"); isSet { if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
h.appURL = appURL h.appURL = appURL

View File

@@ -1,46 +0,0 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateContainerRecordsPersistsImageUpdateAvailability(t *testing.T) {
_, app := newTestSystemWithHub(t)
const (
systemID = "system123"
containerID = "abcdef123456"
image = "nginx:latest"
)
data := &container.Stats{
Id: containerID,
Name: "web",
Image: image,
UpdateAvailable: true,
}
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
var record struct {
Image string `db:"image"`
UpdateAvailable bool `db:"updatable"`
}
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
Where(dbx.HashExp{"id": containerID}).One(&record))
assert.Equal(t, image, record.Image)
assert.True(t, record.UpdateAvailable)
data.UpdateAvailable = false
require.NoError(t, createContainerRecords(app, []*container.Stats{data}, systemID))
require.NoError(t, app.DB().Select("image", "updatable").From("containers").
Where(dbx.HashExp{"id": containerID}).One(&record))
assert.Equal(t, image, record.Image)
assert.False(t, record.UpdateAvailable)
}

View File

@@ -272,15 +272,7 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
// update system record (do this last because it triggers alerts and we need above records to be inserted first) // update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up) systemRecord.Set("status", up)
// Distinguish an idle GPU from a system without GPU data (#2312) systemRecord.Set("info", data.Info)
info := struct {
system.Info
GpuPct *float64 `json:"g,omitempty"`
}{Info: data.Info}
if len(data.Stats.GPUData) > 0 {
info.GpuPct = &data.Info.GpuPct
}
systemRecord.Set("info", info)
if err := txApp.SaveNoValidate(systemRecord); err != nil { if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err return err
} }
@@ -330,11 +322,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
valueStrings := make([]string, 0, len(data)) valueStrings := make([]string, 0, len(data))
for i, service := range data { for i, service := range data {
// Agent payloads can contain null entries. Reject the snapshot before
// executing any queries so existing service records remain intact.
if service == nil {
return fmt.Errorf("null systemd service at index %d", i)
}
suffix := fmt.Sprintf("%d", i) suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix)) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, service.Name) params["id"+suffix] = makeStableHashId(systemId, service.Name)
@@ -376,7 +363,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
valueStrings := make([]string, 0, len(data)) valueStrings := make([]string, 0, len(data))
for i, container := range data { for i, container := range data {
suffix := fmt.Sprintf("%d", i) suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updateAvailable%[1]s}, {:updated})", suffix)) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
params["id"+suffix] = container.Id params["id"+suffix] = container.Id
params["name"+suffix] = container.Name params["name"+suffix] = container.Name
params["image"+suffix] = container.Image params["image"+suffix] = container.Image
@@ -390,10 +377,9 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024) netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
} }
params["net"+suffix] = netBytes params["net"+suffix] = netBytes
params["updateAvailable"+suffix] = container.UpdateAvailable
} }
queryString := fmt.Sprintf( queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updatable, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updatable = excluded.updatable, updated = excluded.updated", "INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
strings.Join(valueStrings, ","), strings.Join(valueStrings, ","),
) )
_, err := app.DB().NewQuery(queryString).Bind(params).Execute() _, err := app.DB().NewQuery(queryString).Bind(params).Execute()

View File

@@ -1,44 +0,0 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateRecordsGPUUtilization(t *testing.T) {
sys, app := newTestSystemWithHub(t)
for _, tc := range []struct {
name string
gpu bool
usage float64
}{
{"no GPU", false, 0},
{"active GPU", true, 42.5},
{"idle GPU", true, 0},
{"GPU removed", false, 0},
} {
t.Run(tc.name, func(t *testing.T) {
data := &system.CombinedData{Info: system.Info{GpuPct: tc.usage, Cpu: 12.5}}
if tc.gpu {
data.Stats.GPUData = map[string]system.GPUData{"0": {Name: "GPU", Usage: tc.usage}}
}
_, err := sys.createRecords(data)
require.NoError(t, err)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
var info map[string]any
require.NoError(t, record.UnmarshalJSONField("info", &info))
assert.Equal(t, 12.5, info["cpu"])
if tc.gpu {
assert.Equal(t, tc.usage, info["g"])
} else {
assert.NotContains(t, info, "g")
}
})
}
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/hub/ws"
@@ -43,17 +42,13 @@ var errSystemExists = errors.New("system exists")
// SystemManager manages a collection of monitored systems and their connections. // SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections. // It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct { type SystemManager struct {
hub hubLike // Hub interface for database and alert operations hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup zfsFetchMap *expirymap.ExpiryMap[zfsFetchState] // Stores last ZFS fetch time/result; TTL is only for cleanup
realtimeMutex sync.Mutex // Protects all realtime worker and subscription state ctx context.Context // Cancelled when the app terminates
activeSubscriptions map[string]*subscriptionInfo // Realtime subscriptions keyed by system ID cancel context.CancelFunc // Cancels ctx and all child system contexts
realtimeWorkerStop chan struct{} // Stops the current realtime worker generation
realtimeWorkerRun bool // Whether a realtime worker has been started
ctx context.Context // Cancelled when the app terminates
cancel context.CancelFunc // Cancels ctx and all child system contexts
} }
// hubLike defines the interface requirements for the hub dependency. // hubLike defines the interface requirements for the hub dependency.
@@ -72,11 +67,10 @@ type hubLike interface {
// The hub must implement the hubLike interface to provide database and alert functionality. // The hub must implement the hubLike interface to provide database and alert functionality.
func NewSystemManager(hub hubLike) *SystemManager { func NewSystemManager(hub hubLike) *SystemManager {
sm := &SystemManager{ sm := &SystemManager{
systems: store.New(map[string]*System{}), systems: store.New(map[string]*System{}),
hub: hub, hub: hub,
smartFetchMap: expirymap.New[smartFetchState](time.Hour), smartFetchMap: expirymap.New[smartFetchState](time.Hour),
zfsFetchMap: expirymap.New[zfsFetchState](time.Hour), zfsFetchMap: expirymap.New[zfsFetchState](time.Hour),
activeSubscriptions: make(map[string]*subscriptionInfo),
} }
sm.ctx, sm.cancel = context.WithCancel(context.Background()) sm.ctx, sm.cancel = context.WithCancel(context.Background())
return sm return sm
@@ -144,7 +138,6 @@ func (sm *SystemManager) bindEventHooks() {
// onTerminate cancels SystemManager context on app shutdown // onTerminate cancels SystemManager context on app shutdown
func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error { func (sm *SystemManager) onTerminate(e *core.TerminateEvent) error {
sm.cancel() sm.cancel()
sm.stopRealtimeWorker()
return e.Next() return e.Next()
} }

View File

@@ -3,27 +3,25 @@ package systems
import ( import (
"encoding/json" "encoding/json"
"strings" "strings"
"sync"
"time" "time"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/utils"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/subscriptions" "github.com/pocketbase/pocketbase/tools/subscriptions"
) )
type subscriptionInfo struct { type subscriptionInfo struct {
subscription string subscription string
connectedClients int connectedClients uint8
fetching bool
} }
type realtimeFetch struct { var (
systemID string activeSubscriptions = make(map[string]*subscriptionInfo)
subscription string workerRunning bool
info *subscriptionInfo tickerStopChan chan struct{}
} realtimeMutex sync.Mutex
)
// onRealtimeConnectRequest handles client connection events for realtime subscriptions. // onRealtimeConnectRequest handles client connection events for realtime subscriptions.
// It cleans up existing subscriptions when a client connects. // It cleans up existing subscriptions when a client connects.
@@ -40,19 +38,6 @@ func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequest
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics. // onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle. // It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error { func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
// Parse with PocketBase's own subscription parser before changing the real
// client. Reject the entire request if any metrics target is inaccessible.
requested := subscriptions.NewDefaultClient()
requested.Subscribe(e.Subscriptions...)
for topic, options := range requested.Subscriptions() {
if !strings.HasPrefix(topic, "rt_metrics") {
continue
}
system, err := sm.GetSystem(options.Query["system"])
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
}
oldSubs := e.Client.Subscriptions() oldSubs := e.Client.Subscriptions()
// after e.Next() is the result of the subscribe request // after e.Next() is the result of the subscribe request
err := e.Next() err := e.Next()
@@ -62,7 +47,14 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
for k, options := range newSubs { for k, options := range newSubs {
if _, ok := oldSubs[k]; !ok { if _, ok := oldSubs[k]; !ok {
if strings.HasPrefix(k, "rt_metrics") { if strings.HasPrefix(k, "rt_metrics") {
sm.addRealtimeSubscription(options.Query["system"], k) systemId := options.Query["system"]
if _, ok := activeSubscriptions[systemId]; !ok {
activeSubscriptions[systemId] = &subscriptionInfo{
subscription: k,
}
}
activeSubscriptions[systemId].connectedClients += 1
sm.onRealtimeSubscriptionAdded()
} }
} }
} }
@@ -76,76 +68,72 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
return err return err
} }
// addRealtimeSubscription tracks a subscriber and starts a worker if necessary. // onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
func (sm *SystemManager) addRealtimeSubscription(systemID, subscription string) { // It ensures only one worker runs at a time.
sm.realtimeMutex.Lock() func (sm *SystemManager) onRealtimeSubscriptionAdded() {
defer sm.realtimeMutex.Unlock() realtimeMutex.Lock()
defer realtimeMutex.Unlock()
if sm.activeSubscriptions == nil { // Start the worker if it's not already running
sm.activeSubscriptions = make(map[string]*subscriptionInfo) if !workerRunning {
} workerRunning = true
info, ok := sm.activeSubscriptions[systemID] // Create a new stop channel for this worker instance
if !ok { tickerStopChan = make(chan struct{})
info = &subscriptionInfo{subscription: subscription} go sm.startRealtimeWorker()
sm.activeSubscriptions[systemID] = info
}
info.connectedClients++
if !sm.realtimeWorkerRun {
sm.realtimeWorkerRun = true
stop := make(chan struct{})
sm.realtimeWorkerStop = stop
go sm.startRealtimeWorker(stop)
} }
} }
// stopRealtimeWorker stops the current worker generation, if any. // checkSubscriptions stops the realtime worker when there are no active subscriptions.
func (sm *SystemManager) stopRealtimeWorker() { // This prevents unnecessary resource usage when no clients are listening for realtime data.
sm.realtimeMutex.Lock() func (sm *SystemManager) checkSubscriptions() {
defer sm.realtimeMutex.Unlock() if !workerRunning || len(activeSubscriptions) > 0 {
sm.stopRealtimeWorkerLocked()
}
func (sm *SystemManager) stopRealtimeWorkerLocked() {
if !sm.realtimeWorkerRun {
return return
} }
close(sm.realtimeWorkerStop)
sm.realtimeWorkerStop = nil realtimeMutex.Lock()
sm.realtimeWorkerRun = false defer realtimeMutex.Unlock()
// Signal the worker to stop
if tickerStopChan != nil {
select {
case tickerStopChan <- struct{}{}:
default:
}
}
// Mark worker as stopped (will be reset when next subscription comes in)
workerRunning = false
} }
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped. // removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed. // It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) { func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
if strings.HasPrefix(subscription, "rt_metrics") { if strings.HasPrefix(subscription, "rt_metrics") {
systemID := options.Query["system"] systemId := options.Query["system"]
sm.realtimeMutex.Lock() if info, ok := activeSubscriptions[systemId]; ok {
if info, ok := sm.activeSubscriptions[systemID]; ok { info.connectedClients -= 1
info.connectedClients--
if info.connectedClients <= 0 { if info.connectedClients <= 0 {
delete(sm.activeSubscriptions, systemID) delete(activeSubscriptions, systemId)
} }
} }
if len(sm.activeSubscriptions) == 0 { sm.checkSubscriptions()
sm.stopRealtimeWorkerLocked()
}
sm.realtimeMutex.Unlock()
} }
} }
// startRealtimeWorker runs the main loop for fetching realtime data from agents. // startRealtimeWorker runs the main loop for fetching realtime data from agents.
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket. // It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) { func (sm *SystemManager) startRealtimeWorker() {
sm.fetchRealtimeDataAndNotify() sm.fetchRealtimeDataAndNotify()
ticker := time.NewTicker(time.Second) tick := time.Tick(1 * time.Second)
defer ticker.Stop()
for { for {
select { select {
case <-stop: case <-tickerStopChan:
return return
case <-ticker.C: case <-tick:
if len(activeSubscriptions) == 0 {
return
}
sm.fetchRealtimeDataAndNotify() sm.fetchRealtimeDataAndNotify()
} }
} }
@@ -153,79 +141,27 @@ func (sm *SystemManager) startRealtimeWorker(stop <-chan struct{}) {
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
func (sm *SystemManager) fetchRealtimeDataAndNotify() { func (sm *SystemManager) fetchRealtimeDataAndNotify() {
for _, fetch := range sm.claimRealtimeFetches() { for systemId, info := range activeSubscriptions {
system, err := sm.GetSystem(fetch.systemID) system, err := sm.GetSystem(systemId)
if err != nil { if err != nil {
sm.finishRealtimeFetch(fetch)
continue continue
} }
go func(fetch realtimeFetch) { go func() {
defer sm.finishRealtimeFetch(fetch)
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
if err != nil { if err != nil {
return return
} }
bytes, err := json.Marshal(data) bytes, err := json.Marshal(data)
if err == nil { if err == nil {
notify(sm.hub, system, fetch.subscription, bytes) notify(sm.hub, info.subscription, bytes)
} }
}(fetch) }()
}
}
// claimRealtimeFetches takes a stable snapshot and marks each selected system as
// in flight. Slow agents are skipped on later ticks until their fetch completes.
func (sm *SystemManager) claimRealtimeFetches() []realtimeFetch {
sm.realtimeMutex.Lock()
defer sm.realtimeMutex.Unlock()
fetches := make([]realtimeFetch, 0, len(sm.activeSubscriptions))
for systemID, info := range sm.activeSubscriptions {
if info.fetching {
continue
}
info.fetching = true
fetches = append(fetches, realtimeFetch{
systemID: systemID,
subscription: info.subscription,
info: info,
})
}
return fetches
}
func (sm *SystemManager) finishRealtimeFetch(fetch realtimeFetch) {
sm.realtimeMutex.Lock()
defer sm.realtimeMutex.Unlock()
// A subscription may have been removed and recreated while the old request
// was running. Only release the exact entry claimed by this request.
if info := sm.activeSubscriptions[fetch.systemID]; info == fetch.info {
info.fetching = false
} }
} }
// notify broadcasts realtime data to all clients subscribed to a specific subscription. // notify broadcasts realtime data to all clients subscribed to a specific subscription.
// Custom topics bypass collection rules, so check current access for every // It iterates through all connected clients and sends the data only to those with matching subscriptions.
// recipient, including clients whose authentication or membership was revoked. func notify(app core.App, subscription string, data []byte) error {
func notify(app core.App, system *System, subscription string, data []byte) error {
shareAll, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
members := make(map[string]struct{})
if shareAll != "true" {
// Refresh once per broadcast so membership changes take effect on the
// next update without querying the database for every recipient.
var recordData struct{ Users string }
if err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
Bind(dbx.Params{"id": system.Id}).One(&recordData); err != nil {
return err
}
var userIDs []string
if err := json.Unmarshal([]byte(recordData.Users), &userIDs); err != nil {
return err
}
for _, id := range userIDs {
members[id] = struct{}{}
}
}
message := subscriptions.Message{ message := subscriptions.Message{
Name: subscription, Name: subscription,
Data: data, Data: data,
@@ -234,13 +170,6 @@ func notify(app core.App, system *System, subscription string, data []byte) erro
if !client.HasSubscription(subscription) { if !client.HasSubscription(subscription) {
continue continue
} }
auth, _ := client.Get(apis.RealtimeClientAuthKey).(*core.Record)
if auth == nil {
continue
}
if _, member := members[auth.Id]; shareAll != "true" && !member {
continue
}
client.Send(message) client.Send(message)
} }
return nil return nil

View File

@@ -1,229 +0,0 @@
package systems
import (
"testing"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/store"
"github.com/pocketbase/pocketbase/tools/subscriptions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRealtimeAuthorization(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", "false")
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "")
app, err := pbtests.NewTestApp(t.TempDir())
require.NoError(t, err)
t.Cleanup(app.Cleanup)
_, err = app.DB().NewQuery(`CREATE TABLE IF NOT EXISTS systems (id TEXT PRIMARY KEY, users TEXT)`).Execute()
require.NoError(t, err)
_, err = app.DB().NewQuery(`INSERT INTO systems (id, users) VALUES ('target', '["member"]')`).Execute()
require.NoError(t, err)
member := core.NewRecord(core.NewAuthCollection("users"))
member.Id = "member"
outsider := core.NewRecord(member.Collection())
outsider.Id = "outsider"
system := &System{Id: "target"}
sm := newRealtimeTestManager()
sm.systems.Set(system.Id, system)
// Keep the lifecycle bookkeeping active without starting an agent worker.
sm.realtimeWorkerRun = true
sm.realtimeWorkerStop = make(chan struct{})
t.Cleanup(sm.stopRealtimeWorker)
topic := `rt_metrics?options={"query":{"system":"target"}}`
for _, tc := range []struct {
name string
auth *core.Record
topic string
share bool
allowed bool
}{
{"guest", nil, topic, false, false},
{"outsider", outsider, topic, false, false},
{"member", member, topic, false, true},
{"missing system", member, `rt_metrics`, false, false},
{"unknown system", member, `rt_metrics?options={"query":{"system":"missing"}}`, false, false},
{"malformed options", member, `rt_metrics?options=invalid`, false, false},
{"prefix variant", outsider, `rt_metrics_extra?options={"query":{"system":"target"}}`, false, false},
{"shared outsider", outsider, topic, true, true},
{"shared guest", nil, topic, true, false},
{"other topic", nil, "systems/*", false, true},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.share {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
}
client := subscriptions.NewDefaultClient()
client.Subscribe("existing")
e := &core.RealtimeSubscribeRequestEvent{
RequestEvent: &core.RequestEvent{App: app, Auth: tc.auth},
Client: client, Subscriptions: []string{tc.topic},
}
called := false
h := &hook.Hook[*core.RealtimeSubscribeRequestEvent]{}
h.BindFunc(sm.onRealtimeSubscribeRequest)
err := h.Trigger(e, func(e *core.RealtimeSubscribeRequestEvent) error {
called = true
client.Unsubscribe()
client.Subscribe(e.Subscriptions...)
return nil
})
if tc.allowed {
require.NoError(t, err)
assert.True(t, called)
} else {
require.Error(t, err)
assert.False(t, called)
assert.True(t, client.HasSubscription("existing"))
assert.False(t, client.HasSubscription(tc.topic))
}
})
}
t.Run("broadcast checks current access", func(t *testing.T) {
client := subscriptions.NewDefaultClient()
client.Subscribe(topic)
app.SubscriptionsBroker().Register(client)
defer app.SubscriptionsBroker().Unregister(client.Id())
secondClient := subscriptions.NewDefaultClient()
secondClient.Subscribe(topic)
app.SubscriptionsBroker().Register(secondClient)
defer app.SubscriptionsBroker().Unregister(secondClient.Id())
check := func(auth *core.Record, allowed bool) {
t.Helper()
client.Set(apis.RealtimeClientAuthKey, auth)
secondClient.Set(apis.RealtimeClientAuthKey, auth)
done := make(chan struct{})
go func() {
notify(app, system, topic, []byte(`{"cpu":42}`))
close(done)
}()
// Even on failure, drain pending sends and join the broadcaster before
// unregistering clients, which closes their channels.
defer func() {
for {
select {
case <-client.Channel():
case <-secondClient.Channel():
case <-done:
return
}
}
}()
var received [2]int
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case msg := <-client.Channel():
received[0]++
assert.Equal(t, topic, msg.Name)
case msg := <-secondClient.Channel():
received[1]++
assert.Equal(t, topic, msg.Name)
case <-done:
want := [2]int{}
if allowed {
want = [2]int{1, 1}
}
assert.Equal(t, want, received)
return
case <-timer.C:
t.Fatal("broadcast did not finish")
}
}
}
check(nil, false)
check(outsider, false)
check(member, true)
_, err := app.DB().NewQuery(`UPDATE systems SET users = '[]'`).Execute()
require.NoError(t, err)
check(member, false)
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
check(outsider, true)
check(nil, false)
})
}
func newRealtimeTestManager() *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
activeSubscriptions: make(map[string]*subscriptionInfo),
}
}
func TestRealtimeFetchesDoNotOverlapPerSystem(t *testing.T) {
sm := newRealtimeTestManager()
sm.activeSubscriptions["one"] = &subscriptionInfo{subscription: "rt_metrics_one"}
sm.activeSubscriptions["two"] = &subscriptionInfo{subscription: "rt_metrics_two"}
first := sm.claimRealtimeFetches()
require.Len(t, first, 2)
assert.Empty(t, sm.claimRealtimeFetches())
sm.finishRealtimeFetch(first[0])
next := sm.claimRealtimeFetches()
require.Len(t, next, 1)
assert.Equal(t, first[0].systemID, next[0].systemID)
sm.finishRealtimeFetch(first[1])
sm.finishRealtimeFetch(next[0])
}
func TestFinishingOldRealtimeFetchDoesNotReleaseReplacement(t *testing.T) {
sm := newRealtimeTestManager()
oldInfo := &subscriptionInfo{subscription: "old"}
sm.activeSubscriptions["system"] = oldInfo
fetch := sm.claimRealtimeFetches()[0]
newInfo := &subscriptionInfo{subscription: "new", fetching: true}
sm.activeSubscriptions["system"] = newInfo
sm.finishRealtimeFetch(fetch)
assert.True(t, newInfo.fetching)
}
func TestRealtimeSubscriptionLifecycle(t *testing.T) {
sm := newRealtimeTestManager()
options := subscriptions.SubscriptionOptions{Query: map[string]string{"system": "system"}}
sm.addRealtimeSubscription("system", "rt_metrics")
sm.addRealtimeSubscription("system", "rt_metrics")
sm.realtimeMutex.Lock()
firstStop := sm.realtimeWorkerStop
assert.True(t, sm.realtimeWorkerRun)
assert.Equal(t, 2, sm.activeSubscriptions["system"].connectedClients)
sm.realtimeMutex.Unlock()
sm.removeRealtimeSubscription("rt_metrics", options)
sm.realtimeMutex.Lock()
assert.True(t, sm.realtimeWorkerRun)
assert.Equal(t, 1, sm.activeSubscriptions["system"].connectedClients)
sm.realtimeMutex.Unlock()
sm.removeRealtimeSubscription("rt_metrics", options)
sm.realtimeMutex.Lock()
assert.False(t, sm.realtimeWorkerRun)
assert.Empty(t, sm.activeSubscriptions)
sm.realtimeMutex.Unlock()
select {
case <-firstStop:
default:
t.Fatal("worker stop channel was not closed")
}
// A later subscription must get a new stop channel owned by its worker.
sm.addRealtimeSubscription("system", "rt_metrics")
sm.realtimeMutex.Lock()
secondStop := sm.realtimeWorkerStop
assert.NotEqual(t, firstStop, secondStop)
sm.realtimeMutex.Unlock()
sm.stopRealtimeWorker()
}

View File

@@ -32,12 +32,13 @@ func (sys *System) FetchAndSaveZfsPools(force bool) error {
sys.recordZfsFetchResult(err, 0) sys.recordZfsFetchResult(err, 0)
return err return err
} }
err = sys.saveZfsPools(zfsData) if zfsData == nil || !zfsData.Complete {
poolCount := 0 err = errIncompleteZfsData
if zfsData != nil { sys.recordZfsFetchResult(err, 0)
poolCount = len(zfsData.Pools) return err
} }
sys.recordZfsFetchResult(err, poolCount) err = sys.saveZfsPools(zfsData)
sys.recordZfsFetchResult(err, len(zfsData.Pools))
return err return err
} }
@@ -78,7 +79,7 @@ func (sys *System) zfsFetchInterval() time.Duration {
// saveZfsPools saves ZFS pool detail data to the zfs_pools collection and // saveZfsPools saves ZFS pool detail data to the zfs_pools collection and
// removes records for pools no longer reported by a complete agent inventory. // removes records for pools no longer reported by a complete agent inventory.
func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error { func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
if zfsData == nil || (!zfsData.CanRefreshPool("zfs") && !zfsData.CanRefreshPool("b:")) { if zfsData == nil || !zfsData.Complete {
return errIncompleteZfsData return errIncompleteZfsData
} }
@@ -88,10 +89,10 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
return err return err
} }
err = hub.RunInTransaction(func(txApp core.App) error { return hub.RunInTransaction(func(txApp core.App) error {
alive := make(map[string]bool, len(zfsData.Pools)) alive := make(map[string]bool, len(zfsData.Pools))
for _, pool := range zfsData.Pools { for _, pool := range zfsData.Pools {
if pool == nil || !zfsData.CanRefreshPool(pool.Name) { if pool == nil {
continue continue
} }
alive[pool.Name] = true alive[pool.Name] = true
@@ -110,7 +111,7 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
return err return err
} }
for _, record := range existing { for _, record := range existing {
if name := record.GetString("name"); zfsData.CanRefreshPool(name) && !alive[name] { if !alive[record.GetString("name")] {
if err := txApp.Delete(record); err != nil { if err := txApp.Delete(record); err != nil {
return err return err
} }
@@ -118,14 +119,6 @@ func (sys *System) saveZfsPools(zfsData *zfs.ZfsData) error {
} }
return nil return nil
}) })
if err != nil {
return err
}
// Report partial failure only after committing healthy backend updates.
if !zfsData.Complete {
return errIncompleteZfsData
}
return nil
} }
func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error { func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection, pool *zfs.PoolDetail) error {
@@ -142,12 +135,10 @@ func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection
record.Set("system", sys.Id) record.Set("system", sys.Id)
record.Set("name", pool.Name) record.Set("name", pool.Name)
record.Set("display_name", pool.DisplayName)
record.Set("health", pool.Health) record.Set("health", pool.Health)
record.Set("size", pool.Size) record.Set("size", pool.Size)
record.Set("alloc", pool.Alloc) record.Set("alloc", pool.Alloc)
record.Set("free", pool.Free) record.Set("free", pool.Free)
record.Set("raw", pool.Raw)
record.Set("scrub", pool.Scrub) record.Set("scrub", pool.Scrub)
record.Set("vdevs", pool.Vdevs) record.Set("vdevs", pool.Vdevs)
record.Set("datasets", pool.Datasets) record.Set("datasets", pool.Datasets)
@@ -181,9 +172,7 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
record.Set("id", recordID) record.Set("id", recordID)
record.Set("system", sys.Id) record.Set("system", sys.Id)
record.Set("name", name) record.Set("name", name)
record.Set("display_name", pool.DisplayName)
record.Set("health", pool.Health) record.Set("health", pool.Health)
record.Set("raw", pool.Raw)
record.Set("size", uint64(pool.Total*gib)) record.Set("size", uint64(pool.Total*gib))
record.Set("alloc", uint64(pool.Used*gib)) record.Set("alloc", uint64(pool.Used*gib))
record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib)) record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib))
@@ -192,15 +181,10 @@ func (sys *System) syncZfsPoolHealth(app core.App, pools map[string]*system.ZfsP
} }
continue continue
} }
if record.GetString("health") == pool.Health && record.GetBool("raw") == pool.Raw && record.GetString("display_name") == pool.DisplayName { if record.GetString("health") == pool.Health {
continue continue
} }
record.Set("display_name", pool.DisplayName)
record.Set("health", pool.Health) record.Set("health", pool.Health)
record.Set("raw", pool.Raw)
record.Set("size", uint64(pool.Total*gib))
record.Set("alloc", uint64(pool.Used*gib))
record.Set("free", uint64(max(pool.Total-pool.Used, 0)*gib))
if err := app.SaveNoValidate(record); err != nil { if err := app.SaveNoValidate(record); err != nil {
return fmt.Errorf("updating ZFS pool health %q: %w", name, err) return fmt.Errorf("updating ZFS pool health %q: %w", name, err)
} }

View File

@@ -123,44 +123,6 @@ func TestSaveZfsPoolsIncompletePreservesRecords(t *testing.T) {
assert.Len(t, records, 1) assert.Len(t, records, 1)
} }
func TestSavePartialBackendInventory(t *testing.T) {
for _, healthy := range []string{"zfs", "btrfs"} {
t.Run(healthy, func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
healthyKey, failedKey := "tank", "b:uuid"
if healthy == "btrfs" {
healthyKey, failedKey = failedKey, healthyKey
}
initial := &zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{
{Name: healthyKey, Alloc: 10}, {Name: failedKey, Alloc: 10},
}}
require.NoError(t, sys.saveZfsPools(initial))
failedID := makeStableHashId(sys.Id, failedKey)
before, err := app.FindRecordById("zfs_pools", failedID)
require.NoError(t, err)
partial := &zfs.ZfsData{CompleteBackends: []string{healthy}, Pools: []*zfs.PoolDetail{
{Name: healthyKey, Alloc: 20}, {Name: failedKey, Alloc: 99},
}}
assert.ErrorIs(t, sys.saveZfsPools(partial), errIncompleteZfsData)
fresh, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, healthyKey))
require.NoError(t, err)
assert.EqualValues(t, 20, fresh.GetInt("alloc"))
cached, err := app.FindRecordById("zfs_pools", failedID)
require.NoError(t, err)
assert.EqualValues(t, 10, cached.GetInt("alloc"))
assert.Equal(t, before.GetDateTime("details_updated"), cached.GetDateTime("details_updated"))
// An empty successful backend can prune, even while the other fails.
partial.Pools = nil
assert.ErrorIs(t, sys.saveZfsPools(partial), errIncompleteZfsData)
records, err := app.FindRecordsByFilter("zfs_pools", "system={:system}", "", 0, 0, map[string]any{"system": sys.Id})
require.NoError(t, err)
require.Len(t, records, 1)
assert.Equal(t, failedKey, records[0].GetString("name"))
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true}))
})
}
}
func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) { func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
sys, app := newTestSystemWithHub(t) sys, app := newTestSystemWithHub(t)
collection, err := app.FindCachedCollectionByNameOrId("zfs_pools") collection, err := app.FindCachedCollectionByNameOrId("zfs_pools")
@@ -189,42 +151,3 @@ func TestSyncZfsPoolHealthWritesOnlyTransitions(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "DEGRADED", record.GetString("health")) assert.Equal(t, "DEGRADED", record.GetString("health"))
} }
func TestZfsRawCapacityPersistence(t *testing.T) {
sys, app := newTestSystemWithHub(t)
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{{Name: "btrfs", Size: 200, Alloc: 10, Raw: true}}}))
record, err := app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "btrfs"))
require.NoError(t, err)
require.True(t, record.GetBool("raw"))
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{"btrfs": {Total: 1, Used: 0.25}}))
record, err = app.FindRecordById("zfs_pools", record.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("raw"))
assert.EqualValues(t, 1024*1024*1024, record.GetInt("size"))
}
func TestBtrfsDisplayNameKeepsRecordIdentity(t *testing.T) {
sys, app := newTestSystemWithHub(t)
key := "b:11111111-1111-4111-8111-111111111111"
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{
key: {DisplayName: "tank", Health: "ONLINE"},
"tank": {Health: "ONLINE"},
}))
id := makeStableHashId(sys.Id, key)
record, err := app.FindRecordById("zfs_pools", id)
require.NoError(t, err)
assert.Equal(t, "tank", record.GetString("display_name"))
require.NoError(t, sys.syncZfsPoolHealth(app, map[string]*system.ZfsPool{key: {DisplayName: "renamed", Health: "ONLINE"}}))
record, err = app.FindRecordById("zfs_pools", id)
require.NoError(t, err)
assert.Equal(t, key, record.GetString("name"))
assert.Equal(t, "renamed", record.GetString("display_name"))
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true, Pools: []*zfs.PoolDetail{
{Name: key, DisplayName: "detail name", Health: "ONLINE"}, {Name: "tank", Health: "ONLINE"},
}}))
record, err = app.FindRecordById("zfs_pools", id)
require.NoError(t, err)
assert.Equal(t, "detail name", record.GetString("display_name"))
_, err = app.FindRecordById("zfs_pools", makeStableHashId(sys.Id, "tank"))
require.NoError(t, err)
}

View File

@@ -3,11 +3,9 @@
package systems_test package systems_test
import ( import (
"encoding/json"
"testing" "testing"
"time" "time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/hub/systems"
@@ -17,42 +15,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCreateRecordsRejectsNullSystemdService(t *testing.T) {
hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup()
records, err := tests.CreateSystems(hub, 1, user.Id, "paused")
require.NoError(t, err)
sys, err := hub.GetSystemManager().GetSystem(records[0].Id)
require.NoError(t, err)
require.NoError(t, systems.CreateSystemdStatsRecords(hub, []*systemd.Service{
{Name: "existing.service", State: systemd.StatusFailed},
}, records[0].Id))
for _, services := range []string{`[null]`, `[{"name":"new.service"},null]`, `[null,{"name":"new.service"}]`} {
for _, encoding := range []string{"json", "cbor"} {
t.Run(encoding+"/"+services, func(t *testing.T) {
var data system.CombinedData
require.NoError(t, json.Unmarshal([]byte(`{"systemd":`+services+`}`), &data))
if encoding == "cbor" {
encoded, err := cbor.Marshal(data)
require.NoError(t, err)
data = system.CombinedData{}
require.NoError(t, cbor.Unmarshal(encoded, &data))
}
_, err := sys.CreateRecords(&data)
require.ErrorContains(t, err, "null systemd service")
var names []string
require.NoError(t, hub.DB().Select("name").From("systemd_services").
Where(dbx.HashExp{"system": records[0].Id}).Column(&names))
assert.Equal(t, []string{"existing.service"}, names)
count, err := hub.CountRecords("system_stats", dbx.HashExp{"system": records[0].Id})
require.NoError(t, err)
assert.Zero(t, count, "invalid snapshot must roll back system stats")
})
}
}
}
func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) { func TestCreateRecordsHandlesSystemdAlertLifecycle(t *testing.T) {
hub, user := tests.GetHubWithUser(t) hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()

View File

@@ -1,127 +0,0 @@
//go:build testing
package hub
import (
"net/netip"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseTrustedProxies(t *testing.T) {
testCases := []struct {
name string
value string
prefixes []string
restricted bool
}{
{
name: "empty",
value: "",
restricted: false,
},
{
name: "blank",
value: " , ",
prefixes: nil,
restricted: true,
},
{
name: "single addresses become host prefixes",
value: "10.0.0.5, 2001:db8::1",
prefixes: []string{"10.0.0.5/32", "2001:db8::1/128"},
restricted: true,
},
{
name: "cidrs are masked",
value: "172.16.5.9/12,fd00::1/64",
prefixes: []string{"172.16.0.0/12", "fd00::/64"},
restricted: true,
},
{
name: "ipv4-mapped entries become ipv4",
value: "::ffff:10.0.0.5, ::ffff:10.0.0.0/104",
prefixes: []string{"10.0.0.5/32", "10.0.0.0/8"},
restricted: true,
},
{
name: "invalid entries are skipped, valid ones kept",
value: "proxy.internal, 10.0.0.0/8, 300.1.1.1, ::ffff:0.0.0.0/64",
prefixes: []string{"10.0.0.0/8"},
restricted: true,
},
{
name: "only invalid entries trust nobody",
value: "proxy.internal",
prefixes: nil,
restricted: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", tc.value)
prefixes, restricted := parseTrustedProxies()
assert.Equal(t, tc.restricted, restricted)
var got []string
for _, p := range prefixes {
got = append(got, p.String())
}
assert.Equal(t, tc.prefixes, got)
})
}
t.Run("unset", func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", "")
os.Unsetenv("TRUSTED_PROXY_IPS")
prefixes, restricted := parseTrustedProxies()
assert.False(t, restricted)
assert.Nil(t, prefixes)
})
t.Run("prefixed env var takes precedence", func(t *testing.T) {
t.Setenv("TRUSTED_PROXY_IPS", "10.0.0.0/8")
t.Setenv("BESZEL_HUB_TRUSTED_PROXY_IPS", "192.168.0.0/16")
prefixes, restricted := parseTrustedProxies()
assert.True(t, restricted)
require.Len(t, prefixes, 1)
assert.Equal(t, "192.168.0.0/16", prefixes[0].String())
})
}
func TestIsTrustedProxy(t *testing.T) {
prefixes := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("2001:db8::/32"),
netip.MustParsePrefix("fe80::/10"),
}
testCases := []struct {
name string
remoteAddr string
trusted bool
}{
{"ipv4 in prefix", "10.20.30.40:51234", true},
{"ipv4 outside prefix", "11.0.0.1:51234", false},
{"ipv6 in prefix", "[2001:db8:1::2]:443", true},
{"ipv6 outside prefix", "[2001:db9::1]:443", false},
{"ipv4-mapped ipv6 matches ipv4 prefix", "[::ffff:10.1.2.3]:80", true},
{"zone is ignored", "[fe80::1%eth0]:80", true},
{"no port", "10.1.2.3", true},
{"empty", "", false},
{"garbage", "not-an-address:80", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.trusted, isTrustedProxy(prefixes, tc.remoteAddr))
})
}
t.Run("empty allowlist trusts nobody", func(t *testing.T) {
assert.False(t, isTrustedProxy(nil, "10.0.0.1:1"))
})
}

View File

@@ -1,27 +0,0 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
c, err := app.FindCollectionByNameOrId("zfs_pools")
if err != nil {
return err
}
c.Fields.Add(&core.TextField{Name: "display_name"})
c.Fields.Add(&core.BoolField{Name: "raw"})
return app.Save(c)
}, func(app core.App) error {
c, err := app.FindCollectionByNameOrId("zfs_pools")
if err != nil {
return err
}
c.Fields.RemoveByName("display_name")
c.Fields.RemoveByName("raw")
return app.Save(c)
})
}

View File

@@ -1,24 +0,0 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
collection.Fields.Add(&core.BoolField{Name: "updatable"})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("containers")
if err != nil {
return err
}
collection.Fields.RemoveByName("updatable")
return app.Save(collection)
})
}

View File

@@ -198,7 +198,6 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
var fanSums map[string]uint64 var fanSums map[string]uint64
fanCount := uint64(0) fanCount := uint64(0)
zfsPoolCounts := make(map[string]uint64) zfsPoolCounts := make(map[string]uint64)
zfsCapacityCounts := make(map[string]uint64)
// Accumulate totals // Accumulate totals
for i := range records { for i := range records {
@@ -351,19 +350,9 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
} }
pool := sum.ZfsPools[name] pool := sum.ZfsPools[name]
if pool == nil { if pool == nil {
pool = &system.ZfsPool{HideUsage: value.HideUsage, HideIO: value.HideIO} pool = &system.ZfsPool{}
sum.ZfsPools[name] = pool sum.ZfsPools[name] = pool
} }
// Never average physical and usable capacity into the same value.
if pool.Raw != value.Raw {
pool.Total, pool.Used = 0, 0
zfsCapacityCounts[name] = 0
}
pool.HideUsage = pool.HideUsage && value.HideUsage
pool.HideIO = pool.HideIO && value.HideIO
pool.DisplayName = value.DisplayName
pool.Raw = value.Raw
zfsCapacityCounts[name]++
pool.Total += value.Total pool.Total += value.Total
pool.Used += value.Used pool.Used += value.Used
pool.ReadBytes += value.ReadBytes pool.ReadBytes += value.ReadBytes
@@ -487,8 +476,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// Average ZFS pool stats. // Average ZFS pool stats.
for name, pool := range sum.ZfsPools { for name, pool := range sum.ZfsPools {
entryCount := zfsPoolCounts[name] entryCount := zfsPoolCounts[name]
pool.Total = twoDecimals(pool.Total / float64(zfsCapacityCounts[name])) pool.Total = twoDecimals(pool.Total / float64(entryCount))
pool.Used = twoDecimals(pool.Used / float64(zfsCapacityCounts[name])) pool.Used = twoDecimals(pool.Used / float64(entryCount))
pool.ReadBytes /= entryCount pool.ReadBytes /= entryCount
pool.WriteBytes /= entryCount pool.WriteBytes /= entryCount
} }

View File

@@ -889,34 +889,3 @@ func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
assert.Equal(t, 35.0, result[2].Cpu) assert.Equal(t, 35.0, result[2].Cpu)
assert.Equal(t, 45.0, result[3].Cpu) assert.Equal(t, 45.0, result[3].Cpu)
} }
func TestAverageSystemStatsSlice_ZfsCapacityModes(t *testing.T) {
for _, raw := range []bool{false, true} {
result := records.AverageSystemStatsSlice([]system.Stats{
{ZfsPools: map[string]*system.ZfsPool{"pool": {Total: 200, Used: 40, Raw: !raw, ReadBytes: 100}}},
{ZfsPools: map[string]*system.ZfsPool{"pool": {Total: 100, Used: 10, Raw: raw, ReadBytes: 300}}},
})
assert.Equal(t, &system.ZfsPool{Total: 100, Used: 10, Raw: raw, ReadBytes: 200}, result.ZfsPools["pool"])
}
}
func TestAverageSystemStatsSlice_ZfsDuplicateCharts(t *testing.T) {
for _, hide := range []bool{false, true} {
result := records.AverageSystemStatsSlice([]system.Stats{
{ZfsPools: map[string]*system.ZfsPool{"pool": {HideUsage: true, HideIO: true}}},
{ZfsPools: map[string]*system.ZfsPool{"pool": {HideUsage: hide, HideIO: hide}}},
})
assert.Equal(t, hide, result.ZfsPools["pool"].HideUsage)
assert.Equal(t, hide, result.ZfsPools["pool"].HideIO)
}
}
func TestAverageSystemStatsSlice_BtrfsDisplayName(t *testing.T) {
result := records.AverageSystemStatsSlice([]system.Stats{
{ZfsPools: map[string]*system.ZfsPool{"b:uuid": {DisplayName: "before", Used: 10}}},
{ZfsPools: map[string]*system.ZfsPool{"b:uuid": {DisplayName: "after", Used: 20}}},
})
require.Len(t, result.ZfsPools, 1)
assert.Equal(t, "after", result.ZfsPools["b:uuid"].DisplayName)
assert.Equal(t, float64(15), result.ZfsPools["b:uuid"].Used)
}

View File

@@ -4,7 +4,6 @@ import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types" import type { ContainerRecord } from "@/types"
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
import { import {
CircleArrowUpIcon,
ClockIcon, ClockIcon,
ContainerIcon, ContainerIcon,
CpuIcon, CpuIcon,
@@ -178,25 +177,11 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => ( header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} /> <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
), ),
cell: ({ getValue, row }) => { cell: ({ getValue }) => {
const val = getValue() as string const val = getValue() as string
return ( return (
<div className="ms-1 xl:w-40 flex items-center gap-2"> <div className="ms-1 xl:w-40 truncate" title={val}>
<span className="truncate" title={val}> {val}
{val}
</span>
{row.original.updatable && (
<Tooltip>
<TooltipTrigger
className="shrink-0 rounded-sm text-emerald-600 dark:text-emerald-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t`Image update available`}
onClick={(event) => event.stopPropagation()}
>
<CircleArrowUpIcon className="size-4" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{t`Image update available`}</TooltipContent>
</Tooltip>
)}
</div> </div>
) )
}, },

View File

@@ -66,7 +66,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
function fetchData(systemId?: string) { function fetchData(systemId?: string) {
pb.collection<ContainerRecord>("containers") pb.collection<ContainerRecord>("containers")
.getList(0, 2000, { .getList(0, 2000, {
fields: "id,name,image,updatable,ports,cpu,memory,net,health,status,system,updated", fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
}) })
.then(({ items }) => { .then(({ items }) => {

View File

@@ -15,7 +15,7 @@ export default function () {
const page = useStore($router) const page = useStore($router)
const [isFirstRun, setFirstRun] = useState(false) const [isFirstRun, setFirstRun] = useState(false)
const [authMethods, setAuthMethods] = useState<AuthMethodsList>() const [authMethods, setAuthMethods] = useState<AuthMethodsList>()
const { resolvedTheme } = useTheme() const { theme } = useTheme()
useEffect(() => { useEffect(() => {
document.title = t`Login` + " / Beszel" document.title = t`Login` + " / Beszel"
@@ -54,7 +54,7 @@ export default function () {
<div <div
className="grid gap-5 w-full px-4 mx-auto" className="grid gap-5 w-full px-4 mx-auto"
// @ts-expect-error // @ts-expect-error
style={{ maxWidth: "21.5em", "--border": resolvedTheme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }} style={{ maxWidth: "21.5em", "--border": theme == "light" ? "hsl(30, 8%, 70%)" : "hsl(220, 3%, 25%)" }}
> >
<div className="absolute top-3 right-3"> <div className="absolute top-3 right-3">
<ModeToggle /> <ModeToggle />

View File

@@ -63,7 +63,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Label className="block" htmlFor="lang"> <Label className="block" htmlFor="lang">
<Trans>Preferred Language</Trans> <Trans>Preferred Language</Trans>
</Label> </Label>
<Select name="lang" value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}> <Select value={i18n.locale} onValueChange={(lang: string) => dynamicActivate(lang)}>
<SelectTrigger id="lang"> <SelectTrigger id="lang">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -14,7 +14,7 @@ import { lazy, useEffect } from "react"
import { $router } from "@/components/router.tsx" import { $router } from "@/components/router.tsx"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import { toast } from "@/components/ui/use-toast.ts" import { toast } from "@/components/ui/use-toast.ts"
import { saveUserSettings } from "@/lib/api" import { pb } from "@/lib/api"
import { $userSettings } from "@/lib/stores.ts" import { $userSettings } from "@/lib/stores.ts"
import type { UserSettings } from "@/types" import type { UserSettings } from "@/types"
import { Separator } from "../../ui/separator" import { Separator } from "../../ui/separator"
@@ -36,13 +36,24 @@ const HeartbeatSettings = lazy(heartbeatSettingsImport)
export async function saveSettings(newSettings: Partial<UserSettings>) { export async function saveSettings(newSettings: Partial<UserSettings>) {
try { try {
await saveUserSettings(newSettings) // get fresh copy of settings
const req = await pb.collection("user_settings").getFirstListItem("", {
fields: "id,settings",
})
// update user settings
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
toast({ toast({
title: t`Settings saved`, title: t`Settings saved`,
description: t`Your user settings have been updated.`, description: t`Your user settings have been updated.`,
}) })
} catch (e) { } catch (e) {
console.error("save settings", e) // console.error('update settings', e)
toast({ toast({
title: t`Failed to save settings`, title: t`Failed to save settings`,
description: t`Check logs for more details.`, description: t`Check logs for more details.`,

View File

@@ -8,7 +8,7 @@ import { useSystemData } from "./system/use-system-data"
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts" import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts" import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { ZfsCharts } from "./system/charts/storage-pool-charts" import { ZfsCharts } from "./system/charts/zfs-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts" import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts" import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"

View File

@@ -95,7 +95,7 @@ export function ChartCard({
className, className,
}: { }: {
title: string title: string
description: React.ReactNode description: string
children: React.ReactNode children: React.ReactNode
grid?: boolean grid?: boolean
empty?: boolean empty?: boolean

View File

@@ -3,7 +3,6 @@ import AreaChartDefault from "@/components/charts/area-chart"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { SystemStatsRecord } from "@/types" import type { SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card" import { ChartCard } from "../chart-card"
import { RawCapacityLabel } from "../raw-capacity-label"
import { Unit } from "@/lib/enums" import { Unit } from "@/lib/enums"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $userSettings } from "@/lib/stores" import { $userSettings } from "@/lib/stores"
@@ -11,11 +10,9 @@ import type { SystemData } from "../use-system-data"
// Accessors for ZFS metrics // Accessors for ZFS metrics
const poolUsage = const poolUsage =
(name: string, raw: boolean) => (name: string) =>
({ stats }: SystemStatsRecord) => { ({ stats }: SystemStatsRecord) =>
const pool = stats?.z?.[name] stats?.z?.[name]?.du ?? 0
return pool && !!pool.raw === raw ? pool.du : null
}
const poolRead = const poolRead =
(name: string) => (name: string) =>
({ stats }: SystemStatsRecord) => ({ stats }: SystemStatsRecord) =>
@@ -29,10 +26,9 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
const { chartData, grid, dataEmpty } = systemData const { chartData, grid, dataEmpty } = systemData
const latest = chartData.systemStats.at(-1)?.stats const latest = chartData.systemStats.at(-1)?.stats
const pool = latest?.z?.[poolName] const pool = latest?.z?.[poolName]
if (!pool || pool.hu) { if (!pool) {
return null return null
} }
const displayName = pool.n || poolName
let poolTotal = pool.d let poolTotal = pool.d
// round to nearest GB // round to nearest GB
if (poolTotal >= 100) { if (poolTotal >= 100) {
@@ -43,8 +39,8 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={`${displayName} ${t`Usage`}`} title={`${poolName} ${t`Usage`}`}
description={pool.raw ? <RawCapacityLabel label={t`Raw usage of storage pool ${displayName}`} /> : t`Usage of storage pool ${displayName}`} description={t`Usage of ZFS pool ${poolName}`}
> >
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
@@ -61,7 +57,7 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
dataPoints={[ dataPoints={[
{ {
label: t`Pool Usage`, label: t`Pool Usage`,
dataKey: poolUsage(poolName, !!pool.raw), dataKey: poolUsage(poolName),
color: 4, color: 4,
opacity: 0.4, opacity: 0.4,
}, },
@@ -74,16 +70,15 @@ export function ZfsPoolUsageChart({ systemData, poolName }: { systemData: System
export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) { export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemData; poolName: string }) {
const { chartData, grid, dataEmpty } = systemData const { chartData, grid, dataEmpty } = systemData
const userSettings = useStore($userSettings) const userSettings = useStore($userSettings)
if (!chartData.systemStats?.length || chartData.systemStats.at(-1)?.stats.z?.[poolName]?.hi) { if (!chartData.systemStats?.length) {
return null return null
} }
const displayName = chartData.systemStats.at(-1)?.stats.z?.[poolName]?.n || poolName
return ( return (
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={`${displayName} I/O`} title={`${poolName} I/O`}
description={t`Throughput of storage pool ${displayName}`} description={t`Throughput of ZFS pool ${poolName}`}
> >
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
@@ -119,15 +114,12 @@ export function ZfsPoolIOChart({ systemData, poolName }: { systemData: SystemDat
export function ZfsCharts({ systemData }: { systemData: SystemData }) { export function ZfsCharts({ systemData }: { systemData: SystemData }) {
const latest = systemData.chartData.systemStats?.at(-1)?.stats const latest = systemData.chartData.systemStats?.at(-1)?.stats
const pools = latest?.z ?? {} const pools = latest?.z ?? {}
const visiblePools = Object.keys(pools) if (Object.keys(pools).length === 0) {
.filter((name) => !pools[name].hu || !pools[name].hi)
.sort((a, b) => (pools[a].n || a).localeCompare(pools[b].n || b, undefined, { numeric: true }) || a.localeCompare(b))
if (visiblePools.length === 0) {
return null return null
} }
return ( return (
<div className="grid xl:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
{visiblePools.map((poolName) => ( {Object.keys(pools).map((poolName) => (
<div key={poolName} className="contents"> <div key={poolName} className="contents">
<ZfsPoolUsageChart systemData={systemData} poolName={poolName} /> <ZfsPoolUsageChart systemData={systemData} poolName={poolName} />
<ZfsPoolIOChart systemData={systemData} poolName={poolName} /> <ZfsPoolIOChart systemData={systemData} poolName={poolName} />

View File

@@ -24,7 +24,7 @@ export function LazySmartTable({ systemId }: { systemId: string }) {
) )
} }
const ZfsTable = lazy(() => import("./storage-pools-table")) const ZfsTable = lazy(() => import("./zfs-table"))
export function LazyZfsTable({ systemId }: { systemId: string }) { export function LazyZfsTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })

View File

@@ -1,25 +0,0 @@
import { t } from "@lingui/core/macro"
import { InfoIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
export function RawCapacityLabel({ label = t`Raw` }: { label?: string }) {
return (
<span className="inline-flex items-center gap-1">
{label}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="About raw capacity"
className="inline-flex rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<InfoIcon className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-64">
{t`Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled.`}
</TooltipContent>
</Tooltip>
</span>
)
}

View File

@@ -75,12 +75,7 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
header: "Name", header: "Name",
}, },
{ {
accessorFn: (row) => { accessorFn: (row) => row.rs || row.rv?.toString(),
if (row.n === "DataUnitsWritten" || row.n === "DataUnitsRead") {
return formatDataUnits(Number(row.rv ?? 0))
}
return row.rs || row.rv?.toString()
},
header: "Value", header: "Value",
}, },
{ {
@@ -108,12 +103,6 @@ function formatCapacity(bytes: number): string {
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}` return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
} }
// Function to format NVMe data units
// (1 unit = 1000 * 512 bytes) as a human-readable size
function formatDataUnits(units: number): string {
return formatCapacity(units * 1000 * 512)
}
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated" const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
export const createColumns = ( export const createColumns = (

View File

@@ -1,9 +1,9 @@
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { subscribeKeys } from "nanostores" import { subscribeKeys } from "nanostores"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { useContainerChartConfigs } from "@/components/charts/hooks" import { useContainerChartConfigs } from "@/components/charts/hooks"
import { pb, queueUserSettings } from "@/lib/api" import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums" import { SystemStatus } from "@/lib/enums"
import { import {
$allSystemsById, $allSystemsById,
@@ -15,7 +15,7 @@ import {
$systems, $systems,
$userSettings, $userSettings,
} from "@/lib/stores" } from "@/lib/stores"
import { chartTimeData, listen, parseSemVer } from "@/lib/utils" import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
import type { import type {
ChartData, ChartData,
ContainerStatsRecord, ContainerStatsRecord,
@@ -35,42 +35,8 @@ export function useSystemData(id: string) {
const systems = useStore($systems) const systems = useStore($systems)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues) const maxValues = useStore($maxValues)
const [grid, _setGrid] = useState<boolean>( const [grid, setGrid] = useBrowserStorage("grid", true)
() => $userSettings.get().grid ?? JSON.parse(localStorage.getItem("besz-grid") ?? "null") ?? true const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
)
const [displayMode, _setDisplayMode] = useState<"default" | "tabs">(
() =>
$userSettings.get().displayMode ??
(JSON.parse(localStorage.getItem("besz-displayMode") || "null") as "default" | "tabs" | null) ??
"default"
)
const applied = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["grid", "displayMode"], (vals) => {
if (!applied.current.has("grid") && vals.grid !== undefined) {
applied.current.add("grid")
_setGrid(vals.grid)
}
if (!applied.current.has("displayMode") && vals.displayMode !== undefined) {
applied.current.add("displayMode")
_setDisplayMode(vals.displayMode)
}
})
}, [])
const setGrid = useCallback((v: boolean) => {
_setGrid(v)
localStorage.setItem("besz-grid", JSON.stringify(v))
$userSettings.setKey("grid", v)
queueUserSettings({ grid: v })
}, [])
const setDisplayMode = useCallback((v: "default" | "tabs") => {
_setDisplayMode(v)
localStorage.setItem("besz-displayMode", JSON.stringify(v))
$userSettings.setKey("displayMode", v)
queueUserSettings({ displayMode: v })
}, [])
const [activeTab, setActiveTabRaw] = useState("core") const [activeTab, setActiveTabRaw] = useState("core")
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"])) const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
const tabsRef = useRef<string[]>(["core", "disk"]) const tabsRef = useRef<string[]>(["core", "disk"])
@@ -205,7 +171,6 @@ export function useSystemData(id: string) {
// get stats when system "changes." (Not just system to system, // get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update) // also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => { useEffect(() => {
const requestId = ++statsRequestId.current
if (!system.id || !chartTime || chartTime === "1m") { if (!system.id || !chartTime || chartTime === "1m") {
return return
} }
@@ -214,6 +179,7 @@ export function useSystemData(id: string) {
const { expectedInterval } = chartTimeData[chartTime] const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}_${chartTime}_system_stats` const ss_cache_key = `${systemId}_${chartTime}_system_stats`
const cs_cache_key = `${systemId}_${chartTime}_container_stats` const cs_cache_key = `${systemId}_${chartTime}_container_stats`
const requestId = ++statsRequestId.current
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
@@ -237,7 +203,7 @@ export function useSystemData(id: string) {
getStats<SystemStatsRecord>("system_stats", systemId, chartTime), getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime), getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => { ]).then(([systemStats, containerStats]) => {
// Ignore responses for a previous system or chart time // If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) { if (requestId !== statsRequestId.current) {
return return
} }

View File

@@ -26,7 +26,6 @@ import {
CheckCircleIcon, CheckCircleIcon,
CircleAlertIcon, CircleAlertIcon,
ClockIcon, ClockIcon,
DatabaseIcon,
HardDriveDownloadIcon, HardDriveDownloadIcon,
HardDriveIcon, HardDriveIcon,
HardDriveUploadIcon, HardDriveUploadIcon,
@@ -36,13 +35,10 @@ import {
RotateCwIcon, RotateCwIcon,
XCircleIcon, XCircleIcon,
XIcon, XIcon,
FolderTreeIcon,
} from "lucide-react" } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { RawCapacityLabel } from "./raw-capacity-label" const ZFS_POOL_FIELDS = "id,system,name,health,size,alloc,free,scrub,details_updated,updated"
const ZFS_POOL_FIELDS = "id,system,name,display_name,health,size,alloc,free,raw,scrub,details_updated,updated"
/** Maps a zpool health string to a Badge variant. */ /** Maps a zpool health string to a Badge variant. */
function healthVariant(health: string): "success" | "warning" | "danger" | "outline" { function healthVariant(health: string): "success" | "warning" | "danger" | "outline" {
@@ -85,30 +81,13 @@ function HeaderButton<T>({ column, name, Icon }: { column: Column<T>; name: stri
) )
} }
function poolType(pool: ZfsPoolRecord): string {
return pool.name.startsWith("b:") ? "Btrfs" : "ZFS"
}
const columns: ColumnDef<ZfsPoolRecord>[] = [ const columns: ColumnDef<ZfsPoolRecord>[] = [
{ {
id: "name", accessorKey: "name",
accessorFn: (pool) => pool.display_name || pool.name, sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={DatabaseIcon} />, header: ({ column }) => <HeaderButton column={column} name={`Pool`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => <span className="font-medium ms-1.5">{getValue() as string}</span>, cell: ({ getValue }) => <span className="font-medium ms-1.5">{getValue() as string}</span>,
}, },
{
id: "type",
accessorFn: poolType,
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={FolderTreeIcon} />,
cell: ({ getValue }) => {
const type = getValue() as string
return (
<Badge variant="outline" className={cn("border-transparent", type === "ZFS" ? "bg-blue-200 text-blue-800" : "bg-yellow-200 text-yellow-800")}>
{type}
</Badge>
)
},
},
{ {
accessorKey: "health", accessorKey: "health",
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health), sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
@@ -123,21 +102,21 @@ const columns: ColumnDef<ZfsPoolRecord>[] = [
accessorFn: (record) => record.size, accessorFn: (record) => record.size,
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>, cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
}, },
{ {
id: "used", id: "used",
accessorFn: (record) => record.alloc, accessorFn: (record) => record.alloc,
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Used`} Icon={HardDriveDownloadIcon} />, header: ({ column }) => <HeaderButton column={column} name={t`Used`} Icon={HardDriveDownloadIcon} />,
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}{row.original.raw ? ` (${t`Raw`})` : ""}</span>, cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
}, },
{ {
id: "free", id: "free",
accessorFn: (record) => record.free, accessorFn: (record) => record.free,
invertSorting: true, invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t({ message: `Free`, context: "Free space" })} Icon={HardDriveUploadIcon} />, header: ({ column }) => <HeaderButton column={column} name={t({ message: `Free`, context: "Free space" })} Icon={HardDriveUploadIcon} />,
cell: ({ getValue, row }) => <span className="ms-1.5 tabular-nums">{row.original.raw ? "-" : formatCapacity(getValue() as number)}</span>, cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{formatCapacity(getValue() as number)}</span>,
}, },
{ {
id: "scrub", id: "scrub",
@@ -222,7 +201,7 @@ const datasetColumns: ColumnDef<ZfsDataset>[] = [
{ {
accessorKey: "name", accessorKey: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={DatabaseIcon} />, header: ({ column }) => <HeaderButton column={column} name={`Dataset`} Icon={HardDriveIcon} />,
cell: ({ getValue }) => <span className="font-mono text-xs">{getValue() as string}</span>, cell: ({ getValue }) => <span className="font-mono text-xs">{getValue() as string}</span>,
}, },
{ {
@@ -331,7 +310,6 @@ function PoolSheet({
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
}) { }) {
const [pool, setPool] = useState<ZfsPoolRecord | null>(null) const [pool, setPool] = useState<ZfsPoolRecord | null>(null)
const titleRef = useRef<HTMLHeadingElement>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
useEffect(() => { useEffect(() => {
@@ -364,30 +342,23 @@ function PoolSheet({
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent className="w-full sm:max-w-220 gap-0 overflow-y-auto">
className="w-full sm:max-w-220 gap-0 overflow-y-auto"
onOpenAutoFocus={(event) => {
event.preventDefault()
titleRef.current?.focus()
}}
>
<SheetHeader className="mb-0 border-b"> <SheetHeader className="mb-0 border-b">
<SheetTitle ref={titleRef} tabIndex={-1} className="flex items-center gap-2 outline-none"> <SheetTitle className="flex items-center gap-2">
{pool ? (pool.display_name || pool.name) : `Storage Pool`} {pool ? pool.name : `ZFS Pool`}
{pool && <Badge variant={healthVariantValue}>{health}</Badge>} {pool && <Badge variant={healthVariantValue}>{health}</Badge>}
</SheetTitle> </SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1"> <SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
{pool?.size ? formatCapacity(pool.size) : null} {pool?.size ? formatCapacity(pool.size) : null}
{pool?.raw && <RawCapacityLabel />}
{pool?.alloc ? ( {pool?.alloc ? (
<> <>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" /> <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<span> <span>
<Trans>Used</Trans>: {formatCapacity(pool.alloc)}{pool.raw ? ` (${t`Raw`})` : ""} <Trans>Used</Trans>: {formatCapacity(pool.alloc)}
</span> </span>
</> </>
) : null} ) : null}
{pool?.free && !pool.raw ? ( {pool?.free ? (
<> <>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" /> <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<span> <span>
@@ -584,7 +555,6 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
const table = useReactTable({ const table = useReactTable({
data: zfsPools || ([] as ZfsPoolRecord[]), data: zfsPools || ([] as ZfsPoolRecord[]),
columns: tableColumns, columns: tableColumns,
initialState: { sorting: [{ id: "name", desc: false }] },
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
@@ -592,7 +562,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => { globalFilterFn: (row, _columnId, filterValue) => {
const pool = row.original const pool = row.original
const searchString = `${pool.display_name ?? ""} ${pool.name} ${poolType(pool)} ${pool.health ?? ""}`.toLowerCase() const searchString = `${pool.name} ${pool.health ?? ""}`.toLowerCase()
return (filterValue as string) return (filterValue as string)
.toLowerCase() .toLowerCase()
.split(" ") .split(" ")
@@ -617,7 +587,7 @@ export default function ZfsTable({ systemId }: { systemId?: string }) {
<CardHeader className="p-0 mb-3 sm:mb-4"> <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="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1"> <div className="px-2 sm:px-1">
<CardTitle className="mb-2">Storage Pools</CardTitle> <CardTitle className="mb-2">ZFS</CardTitle>
<CardDescription className="flex"> <CardDescription className="flex">
<Trans>Click on a pool to view vdev and dataset details.</Trans> <Trans>Click on a pool to view vdev and dataset details.</Trans>
</CardDescription> </CardDescription>

View File

@@ -193,7 +193,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: ({ info }) => info.g, accessorFn: ({ info }) => info.g || undefined,
id: "gpu", id: "gpu",
name: () => "GPU", name: () => "GPU",
cell: (info) => { cell: (info) => {

View File

@@ -1,6 +1,5 @@
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { subscribeKeys } from "nanostores"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
import { import {
type ColumnDef, type ColumnDef,
@@ -27,7 +26,7 @@ import {
Settings2Icon, Settings2Icon,
XIcon, XIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { memo, useEffect, useMemo, useRef, useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
@@ -43,9 +42,8 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SystemStatus } from "@/lib/enums" import { SystemStatus } from "@/lib/enums"
import { queueUserSettings } from "@/lib/api" import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores"
import { $downSystems, $pausedSystems, $systems, $upSystems, $userSettings } from "@/lib/stores" import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
import { cn, runOnce } from "@/lib/utils"
import type { SystemRecord } from "@/types" import type { SystemRecord } from "@/types"
import AlertButton from "../alerts/alert-button" import AlertButton from "../alerts/alert-button"
import { $router, Link } from "../router" import { $router, Link } from "../router"
@@ -64,83 +62,14 @@ export default function SystemsTable() {
const pausedSystems = $pausedSystems.get() const pausedSystems = $pausedSystems.get()
const { i18n, t } = useLingui() const { i18n, t } = useLingui()
const [filter, setFilter] = useState<string>("") const [filter, setFilter] = useState<string>("")
const [statusFilter, setStatusFilter] = useState<StatusFilter>( const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
() => const [sorting, setSorting] = useBrowserStorage<SortingState>(
$userSettings.get().statusFilter ?? "sortMode",
(JSON.parse(localStorage.getItem("besz-statusFilter") || "null") as StatusFilter | null) ?? [{ id: "system", desc: false }],
"all" sessionStorage
)
const [sorting, setSorting] = useState<SortingState>(
() =>
$userSettings.get().sortMode ??
JSON.parse(sessionStorage.getItem("besz-sortMode") || "null") ?? [{ id: "system", desc: false }]
) )
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
() => $userSettings.get().cols ?? JSON.parse(localStorage.getItem("besz-cols") || "{}")
)
// Apply settings from server once they load (handles incognito / new devices)
const applied = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["cols", "statusFilter", "viewMode", "sortMode"], (vals) => {
if (!applied.current.has("cols") && vals.cols !== undefined) {
applied.current.add("cols")
setColumnVisibility(vals.cols)
}
if (!applied.current.has("statusFilter") && vals.statusFilter !== undefined) {
applied.current.add("statusFilter")
setStatusFilter(vals.statusFilter)
}
if (!applied.current.has("viewMode") && vals.viewMode !== undefined) {
applied.current.add("viewMode")
setViewMode(vals.viewMode)
}
if (!applied.current.has("sortMode") && vals.sortMode !== undefined) {
applied.current.add("sortMode")
setSorting(vals.sortMode)
}
})
}, [])
const handleColumnVisibilityChange = useCallback(
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
setColumnVisibility((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
localStorage.setItem("besz-cols", JSON.stringify(next))
$userSettings.setKey("cols", next)
queueUserSettings({ cols: next })
return next
})
},
[]
)
const handleStatusFilterChange = useCallback((value: string) => {
const next = value as StatusFilter
setStatusFilter(next)
localStorage.setItem("besz-statusFilter", JSON.stringify(next))
$userSettings.setKey("statusFilter", next)
queueUserSettings({ statusFilter: next })
}, [])
const handleViewModeChange = useCallback((view: string) => {
const next = view as ViewMode
setViewMode(next)
localStorage.setItem("besz-viewMode", JSON.stringify(next))
$userSettings.setKey("viewMode", next)
queueUserSettings({ viewMode: next })
}, [])
const handleSortingChange = useCallback((updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
sessionStorage.setItem("besz-sortMode", JSON.stringify(next))
$userSettings.setKey("sortMode", next)
queueUserSettings({ sortMode: next })
return next
})
}, [])
const locale = i18n.locale const locale = i18n.locale
@@ -158,12 +87,10 @@ export default function SystemsTable() {
return Object.values(pausedSystems) ?? [] return Object.values(pausedSystems) ?? []
}, [data, statusFilter]) }, [data, statusFilter])
const [viewMode, setViewMode] = useState<ViewMode>( const [viewMode, setViewMode] = useBrowserStorage<ViewMode>(
() => "viewMode",
$userSettings.get().viewMode ?? // show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
(JSON.parse(localStorage.getItem("besz-viewMode") || "null") as ViewMode | null) ?? window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table"
// show grid view on mobile if there are less than 200 systems (looks better but table is more efficient)
(window.innerWidth < 1024 && filteredData.length < 200 ? "grid" : "table")
) )
useEffect(() => { useEffect(() => {
@@ -178,11 +105,11 @@ export default function SystemsTable() {
data: filteredData, data: filteredData,
columns: columnDefs, columns: columnDefs,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onSortingChange: handleSortingChange, onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: handleColumnVisibilityChange, onColumnVisibilityChange: setColumnVisibility,
state: { state: {
sorting, sorting,
columnFilters, columnFilters,
@@ -254,7 +181,11 @@ export default function SystemsTable() {
<Trans>Layout</Trans> <Trans>Layout</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuRadioGroup className="px-1 pb-1" value={viewMode} onValueChange={handleViewModeChange}> <DropdownMenuRadioGroup
className="px-1 pb-1"
value={viewMode}
onValueChange={(view) => setViewMode(view as ViewMode)}
>
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2"> <DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutListIcon className="size-4" /> <LayoutListIcon className="size-4" />
<Trans>Table</Trans> <Trans>Table</Trans>
@@ -275,7 +206,7 @@ export default function SystemsTable() {
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
className="px-1 pb-1" className="px-1 pb-1"
value={statusFilter} value={statusFilter}
onValueChange={handleStatusFilterChange} onValueChange={(value) => setStatusFilter(value as StatusFilter)}
> >
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}> <DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
<Trans>All Systems</Trans> <Trans>All Systems</Trans>
@@ -314,9 +245,7 @@ export default function SystemsTable() {
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
e.preventDefault() e.preventDefault()
handleSortingChange([ setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
])
}} }}
key={column.id} key={column.id}
> >

View File

@@ -1,7 +1,6 @@
import { createContext, useContext, useEffect, useState } from "react" import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system" type Theme = "dark" | "light" | "system"
type ResolvedTheme = "dark" | "light"
type ThemeProviderProps = { type ThemeProviderProps = {
children: React.ReactNode children: React.ReactNode
@@ -11,13 +10,11 @@ type ThemeProviderProps = {
type ThemeProviderState = { type ThemeProviderState = {
theme: Theme theme: Theme
resolvedTheme: ResolvedTheme
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
} }
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: "system", theme: "system",
resolvedTheme: "light",
setTheme: () => null, setTheme: () => null,
} }
@@ -30,28 +27,24 @@ export function ThemeProvider({
...props ...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme) const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem(storageKey) as Theme) || defaultTheme)
const [systemDark, setSystemDark] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches)
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)")
const onChange = (event: MediaQueryListEvent) => setSystemDark(event.matches)
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}, [])
const resolvedTheme = theme === "system" ? (systemDark ? "dark" : "light") : theme
useEffect(() => { useEffect(() => {
const root = window.document.documentElement const root = window.document.documentElement
root.classList.remove("light", "dark") root.classList.remove("light", "dark")
root.classList.add(resolvedTheme)
}, [resolvedTheme]) if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = { const value = {
theme, theme,
resolvedTheme,
setTheme: (theme: Theme) => { setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme) localStorage.setItem(storageKey, theme)
setTheme(theme) setTheme(theme)

View File

@@ -2,10 +2,9 @@ import { t } from "@lingui/core/macro"
import PocketBase from "pocketbase" import PocketBase from "pocketbase"
import { basePath } from "@/components/router" import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import type { ChartTimes, UserSettings } from "@/types" import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores" import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { chartTimeData, debounce } from "./utils" import { chartTimeData } from "./utils"
/** PocketBase JS Client */ /** PocketBase JS Client */
export const pb = new PocketBase(basePath) export const pb = new PocketBase(basePath)
@@ -13,7 +12,7 @@ export const pb = new PocketBase(basePath)
export const isAdmin = () => pb.authStore.record?.role === "admin" export const isAdmin = () => pb.authStore.record?.role === "admin"
export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly" export const isReadOnlyUser = () => pb.authStore.record?.role === "readonly"
const verifyAuth = () => { export const verifyAuth = () => {
pb.collection("users") pb.collection("users")
.authRefresh() .authRefresh()
.catch(() => { .catch(() => {
@@ -26,22 +25,6 @@ const verifyAuth = () => {
}) })
} }
const verifyAuthDebounced = debounce(verifyAuth, 100)
// verify the session whenever any API request returns a 4xx response (e.g. an
// expired JWT). The auth-refresh endpoint is excluded to avoid a loop, since
// it returns 401 itself when the token is no longer valid.
pb.afterSend = (response, data) => {
if (
(response.status === 401 || response.status === 403) &&
pb.authStore.token &&
!response.url.includes("auth-refresh")
) {
verifyAuthDebounced()
}
return data
}
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */ /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export function logOut() { export function logOut() {
$allSystemsByName.set({}) $allSystemsByName.set({})
@@ -53,45 +36,11 @@ export function logOut() {
pb.realtime.unsubscribe() pb.realtime.unsubscribe()
} }
/** Save a partial update to user settings in database immediately */
export async function saveUserSettings(newSettings: Partial<UserSettings>) {
// get fresh copy of settings so concurrent changes aren't overwritten
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "id,settings" })
const updatedSettings = await pb.collection("user_settings").update(req.id, {
settings: {
...req.settings,
...newSettings,
},
})
$userSettings.set(updatedSettings.settings)
}
// keys queued by queueUserSettings, flushed together in a single request so that
// two debounced saves for different keys can't race each other's read-modify-write
// and silently drop one of the changes
let queuedSettings: Partial<UserSettings> = {}
const flushQueuedSettings = debounce(() => {
const toSave = queuedSettings
queuedSettings = {}
if (Object.keys(toSave).length === 0) {
return
}
saveUserSettings(toSave).catch(console.error)
}, 1000)
/** Queue a partial user settings update, merging with any other pending keys and saving them together after a debounce window */
export function queueUserSettings(newSettings: Partial<UserSettings>) {
queuedSettings = { ...queuedSettings, ...newSettings }
flushQueuedSettings()
}
/** Fetch or create user settings in database */ /** Fetch or create user settings in database */
export async function updateUserSettings() { export async function updateUserSettings() {
try { try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" }) const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings) $userSettings.set(req.settings)
dynamicActivate(req.settings.lang || getLocale())
return return
} catch (e) { } catch (e) {
console.error("get settings", e) console.error("get settings", e)
@@ -100,7 +49,6 @@ export async function updateUserSettings() {
try { try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id }) const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
$userSettings.set(createdSettings.settings) $userSettings.set(createdSettings.settings)
dynamicActivate(createdSettings.settings.lang || getLocale())
} catch (e) { } catch (e) {
console.error("create settings", e) console.error("create settings", e)
} }

View File

@@ -1,6 +1,6 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */ /** biome-ignore-all lint/suspicious/noAssignInExpressions: it's fine :) */
import type { PreinitializedMapStore } from "nanostores" import type { PreinitializedMapStore } from "nanostores"
import { pb } from "@/lib/api" import { pb, verifyAuth } from "@/lib/api"
import { import {
$allSystemsById, $allSystemsById,
$allSystemsByName, $allSystemsByName,
@@ -167,6 +167,11 @@ export async function subscribe() {
export async function refresh() { export async function refresh() {
try { try {
const records = await fetchSystems() const records = await fetchSystems()
if (!records.length) {
// No systems found, verify authentication
verifyAuth()
return
}
for (const record of records) { for (const record of records) {
add(record) add(record)
} }

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n" "Language: ar\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 19:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Arabic\n" "Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n" "Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
@@ -97,7 +97,7 @@ msgstr "5 دقائق"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "إجراءات" msgstr "إجراءات"
@@ -196,7 +196,7 @@ msgstr "هل أنت متأكد؟"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا." msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "المتاح" msgstr "المتاح"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "القدرات" msgstr "القدرات"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "السعة" msgstr "السعة"
@@ -391,14 +391,14 @@ msgstr "تحقق من خدمة المراقبة الخاصة بك"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "تحقق من خدمة الإشعارات الخاصة بك" msgstr "تحقق من خدمة الإشعارات الخاصة بك"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "أخطاء المجموع الاختباري" msgstr "أخطاء المجموع الاختباري"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "مسح" msgstr "مسح"
@@ -411,7 +411,7 @@ msgstr "انقر على حاوية لعرض مزيد من المعلومات."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "انقر على جهاز لعرض مزيد من المعلومات." msgstr "انقر على جهاز لعرض مزيد من المعلومات."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "انقر على مجموعة تخزين لعرض تفاصيل vdev ومجموعة البيانات." msgstr "انقر على مجموعة تخزين لعرض تفاصيل vdev ومجموعة البيانات."
@@ -503,7 +503,7 @@ msgstr "نسخ الاسم"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "نسخ المفتاح العام" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -869,14 +869,14 @@ msgstr "فشل: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "المراوح" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}
msgid "Forgot password?" msgid "Forgot password?"
msgstr "هل نسيت كلمة المرور؟" msgstr "هل نسيت كلمة المرور؟"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "المساحة الحرة" msgstr "المساحة الحرة"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "شبكة" msgstr "شبكة"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "الصحة" msgstr "الصحة"
@@ -1146,7 +1146,7 @@ msgstr "استخدام الذاكرة للحاويات"
msgid "Model" msgid "Model"
msgstr "الموديل" msgstr "الموديل"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "نقطة الربط" msgstr "نقطة الربط"
@@ -1185,7 +1185,7 @@ msgstr "وحدة الشبكة"
msgid "No" msgid "No"
msgstr "لا" msgstr "لا"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "لا تتوفر بيانات تفصيلية لمجموعة التخزين هذه." msgstr "لا تتوفر بيانات تفصيلية لمجموعة التخزين هذه."
@@ -1212,7 +1212,7 @@ msgstr "لا توجد سمات S.M.A.R.T. متاحة لهذا الجهاز."
msgid "No systems found." msgid "No systems found."
msgstr "لم يتم العثور على أنظمة." msgstr "لم يتم العثور على أنظمة."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "لا شيء" msgstr "لا شيء"
@@ -1255,7 +1255,7 @@ msgstr "كلمة مرور لمرة واحدة"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "فتح القائمة" msgstr "فتح القائمة"
@@ -1346,10 +1346,6 @@ msgstr "دائم"
msgid "Persistence" msgid "Persistence"
msgstr "الاستمرارية" msgstr "الاستمرارية"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "مساحة الجهاز الفعلية. السعة الفعلية القابلة للاستخدام غير معروفة. تم تعطيل تنبيهات استخدام أقراص المجموعة."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات." msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
@@ -1383,11 +1379,11 @@ msgstr "يرجى الاطلاع على <0>التوثيق</0> للحصول على
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "يرجى تسجيل الدخول إلى حسابك" msgstr "يرجى تسجيل الدخول إلى حسابك"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "صحة مجموعة التخزين" msgstr "صحة مجموعة التخزين"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "استخدام مجموعة التخزين" msgstr "استخدام مجموعة التخزين"
@@ -1420,7 +1416,7 @@ msgstr "تم بدء العملية"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "المفتاح العام" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "عمق الدور"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "ساعات الهدوء" msgstr "ساعات الهدوء"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "الخام"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "الاستخدام الخام لمجموعة التخزين {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "الاستخدام الخام لمجموعة التخزين {displayName
msgid "Read" msgid "Read"
msgstr "قراءة" msgstr "قراءة"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "أخطاء القراءة" msgstr "أخطاء القراءة"
@@ -1469,7 +1454,7 @@ msgstr "تم الاستلام"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "تحديث" msgstr "تحديث"
@@ -1658,7 +1643,7 @@ msgstr "وقت البدء"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "الحالة" msgstr "الحالة"
@@ -1686,7 +1671,7 @@ msgstr "استخدام التبديل"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "تبديل السمة" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "النظام"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "سرعات مراوح النظام (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "معدل نقل {extraFsName}" msgstr "معدل نقل {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "معدل نقل مجموعة التخزين {displayName}" msgstr "معدل نقل البيانات لمجموعة ZFS {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1912,7 +1897,6 @@ msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "النوع" msgstr "النوع"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "رمز مميز عالمي" msgstr "رمز مميز عالمي"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "غير معروفة" msgstr "غير معروفة"
@@ -1961,7 +1945,7 @@ msgstr "تحديث"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "تم التحديث" msgstr "تم التحديث"
@@ -1984,20 +1968,20 @@ msgstr "مدة التشغيل"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "الاستخدام" msgstr "الاستخدام"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "استخدام مجموعة التخزين {displayName}" msgstr "استخدام مجموعة ZFS {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "مستخدم" msgstr "مستخدم"
@@ -2074,7 +2058,7 @@ msgstr "أمر ويندوز"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "أمر ويندوز"
msgid "Write" msgid "Write"
msgstr "كتابة" msgstr "كتابة"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "أخطاء الكتابة" msgstr "أخطاء الكتابة"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n" "Language: bg\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Bulgarian\n" "Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -97,7 +97,7 @@ msgstr "5 минути"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Действия" msgstr "Действия"
@@ -196,7 +196,7 @@ msgstr "Сигурни ли сте?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Автоматичното копиране изисква защитен контескт." msgstr "Автоматичното копиране изисква защитен контескт."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Налично" msgstr "Налично"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Възможности" msgstr "Възможности"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Капацитет" msgstr "Капацитет"
@@ -391,14 +391,14 @@ msgstr "Проверете мониторинг услугата си"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Провери услугата си за удостоверяване" msgstr "Провери услугата си за удостоверяване"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Грешки в контролната сума" msgstr "Грешки в контролната сума"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Изчисти" msgstr "Изчисти"
@@ -411,7 +411,7 @@ msgstr "Кликнете върху контейнер, за да видите
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Кликнете върху устройство, за да видите повече информация." msgstr "Кликнете върху устройство, за да видите повече информация."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Щракнете върху пул, за да видите подробности за vdev и наборите от данни." msgstr "Щракнете върху пул, за да видите подробности за vdev и наборите от данни."
@@ -503,7 +503,7 @@ msgstr "Копирай име"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Копирай публичния ключ" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -869,14 +869,14 @@ msgstr "Неуспешни: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Вентилатори" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "За <0>{min}</0> {min, plural, one {минута} other {минути}}
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Забравена парола?" msgstr "Забравена парола?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Свободно" msgstr "Свободно"
@@ -928,7 +928,7 @@ msgstr "Глобален"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -948,13 +948,13 @@ msgid "Grid"
msgstr "Мрежово" msgstr "Мрежово"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Здраве" msgstr "Здраве"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -1146,7 +1146,7 @@ msgstr "Използване на паметта от контейнерите"
msgid "Model" msgid "Model"
msgstr "Модел" msgstr "Модел"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Точка на монтиране" msgstr "Точка на монтиране"
@@ -1185,7 +1185,7 @@ msgstr "Единица за измерване на скорост"
msgid "No" msgid "No"
msgstr "Не" msgstr "Не"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Няма подробни данни за този пул." msgstr "Няма подробни данни за този пул."
@@ -1212,7 +1212,7 @@ msgstr "Няма налични S.M.A.R.T. атрибути за това уст
msgid "No systems found." msgid "No systems found."
msgstr "Няма намерени системи." msgstr "Няма намерени системи."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Няма" msgstr "Няма"
@@ -1255,7 +1255,7 @@ msgstr "Еднократна парола"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Отвори менюто" msgstr "Отвори менюто"
@@ -1346,10 +1346,6 @@ msgstr "Постоянен"
msgid "Persistence" msgid "Persistence"
msgstr "Устойчивост" msgstr "Устойчивост"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Физическо пространство на устройството. Действителният използваем капацитет е неизвестен. Сигналите за използване на диска на пула са изключени."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Моля <0>конфигурурай SMTP сървър</0> за да се подсигуриш, че тревогите са доставени." msgstr "Моля <0>конфигурурай SMTP сървър</0> за да се подсигуриш, че тревогите са доставени."
@@ -1383,11 +1379,11 @@ msgstr "Моля виж <0>документацията</0> за инструк
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Моля влез в акаунта ти" msgstr "Моля влез в акаунта ти"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Състояние на пула" msgstr "Състояние на пула"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Използване на пула" msgstr "Използване на пула"
@@ -1420,7 +1416,7 @@ msgstr "Процесът стартира"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Публичен ключ" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Дълбочина на опашката"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Тихи часове" msgstr "Тихи часове"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Сурово"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Сурово използване на пула {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Сурово използване на пула {displayName}"
msgid "Read" msgid "Read"
msgstr "Прочети" msgstr "Прочети"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Грешки при четене" msgstr "Грешки при четене"
@@ -1469,7 +1454,7 @@ msgstr "Получени"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Опресни" msgstr "Опресни"
@@ -1658,7 +1643,7 @@ msgstr "Начален час"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Състояние" msgstr "Състояние"
@@ -1686,7 +1671,7 @@ msgstr "Използване на swap"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Смени темата" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "Система"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Скорости на вентилаторите на системата (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "Това ще доведе до трайно изтриване на в
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Пропускателна способност на {extraFsName}" msgstr "Пропускателна способност на {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Пропускателна способност на пула {displayName}" msgstr "Пропускателна способност на ZFS пул {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1834,7 +1819,7 @@ msgstr "Общо изпратени данни за всеки интерфей
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Общо време, прекарано в четене/запис (може да надвиши 100%)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
@@ -1912,7 +1897,6 @@ msgstr "Задейства се, когато употребата на няко
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Тип" msgstr "Тип"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Универсален тоукън" msgstr "Универсален тоукън"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Неизвестна" msgstr "Неизвестна"
@@ -1961,7 +1945,7 @@ msgstr "Актуализирай"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Актуализирано" msgstr "Актуализирано"
@@ -1984,20 +1968,20 @@ msgstr "Време на работа"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Употреба" msgstr "Употреба"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Използване на пула {displayName}" msgstr "Използване на ZFS пул {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Използвани" msgstr "Използвани"
@@ -2074,7 +2058,7 @@ msgstr "Команда Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Команда Windows"
msgid "Write" msgid "Write"
msgstr "Запиши" msgstr "Запиши"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Грешки при запис" msgstr "Грешки при запис"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n" "Language: cs\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Czech\n" "Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n" "Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -61,7 +61,7 @@ msgstr "1 hodina"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 minute" msgid "1 minute"
@@ -78,7 +78,7 @@ msgstr "12 hodin"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -91,13 +91,13 @@ msgstr "30 dní"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 min"
#. Table column #. Table column
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Akce" msgstr "Akce"
@@ -154,7 +154,7 @@ msgstr "Po nastavení proměnných prostředí restartujte hub Beszel, aby se zm
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -196,7 +196,7 @@ msgstr "Jste si jistý?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Automatická kopie vyžaduje zabezpečený kontext." msgstr "Automatická kopie vyžaduje zabezpečený kontext."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Dostupné" msgstr "Dostupné"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Schopnosti" msgstr "Schopnosti"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Kapacita" msgstr "Kapacita"
@@ -391,14 +391,14 @@ msgstr "Zkontrolujte svou monitorovací službu"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Zkontrolujte službu upozornění" msgstr "Zkontrolujte službu upozornění"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Chyby kontrolního součtu" msgstr "Chyby kontrolního součtu"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Vymazat" msgstr "Vymazat"
@@ -411,7 +411,7 @@ msgstr "Klikněte na kontejner pro zobrazení dalších informací."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Klikněte na zařízení pro zobrazení dalších informací." msgstr "Klikněte na zařízení pro zobrazení dalších informací."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Kliknutím na fond zobrazíte podrobnosti o vdev a datových sadách." msgstr "Kliknutím na fond zobrazíte podrobnosti o vdev a datových sadách."
@@ -503,7 +503,7 @@ msgstr "Kopírovat název"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Zkopírovat veřejný klíč" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -647,7 +647,7 @@ msgstr "Popis"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
msgid "Detail" msgid "Detail"
msgstr "" msgstr "Detail"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Device" msgid "Device"
@@ -869,14 +869,14 @@ msgstr "Neúspěšné: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Ventilátory" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Zapomněli jste heslo?" msgstr "Zapomněli jste heslo?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Volné" msgstr "Volné"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "Mřížka" msgstr "Mřížka"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Zdraví" msgstr "Zdraví"
@@ -1146,7 +1146,7 @@ msgstr "Využití paměti kontejnery"
msgid "Model" msgid "Model"
msgstr "" msgstr ""
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Bod připojení" msgstr "Bod připojení"
@@ -1185,7 +1185,7 @@ msgstr "Síťová jednotka"
msgid "No" msgid "No"
msgstr "Ne" msgstr "Ne"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Pro tento fond nejsou k dispozici podrobné údaje." msgstr "Pro tento fond nejsou k dispozici podrobné údaje."
@@ -1212,7 +1212,7 @@ msgstr "Pro toto zařízení nejsou k dispozici žádné atributy S.M.A.R.T."
msgid "No systems found." msgid "No systems found."
msgstr "Nenalezeny žádné systémy." msgstr "Nenalezeny žádné systémy."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Žádné" msgstr "Žádné"
@@ -1255,7 +1255,7 @@ msgstr "Jednorázové heslo"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Otevřít menu" msgstr "Otevřít menu"
@@ -1346,10 +1346,6 @@ msgstr "Trvalý"
msgid "Persistence" msgid "Persistence"
msgstr "Trvalost" msgstr "Trvalost"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Fyzické místo na zařízení. Skutečná využitelná kapacita není známa. Upozornění na využití disků fondu jsou vypnutá."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena." msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena."
@@ -1383,11 +1379,11 @@ msgstr "Instrukce naleznete v <0>dokumentaci</0>."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Přihlaste se prosím k vašemu účtu" msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Stav fondu" msgstr "Stav fondu"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Využití fondu" msgstr "Využití fondu"
@@ -1420,7 +1416,7 @@ msgstr "Proces spuštěn"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Veřejný klíč" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Hloubka fronty"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Tiché hodiny" msgstr "Tiché hodiny"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Hrubé"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Hrubé využití fondu {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Hrubé využití fondu {displayName}"
msgid "Read" msgid "Read"
msgstr "Číst" msgstr "Číst"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Chyby čtení" msgstr "Chyby čtení"
@@ -1469,7 +1454,7 @@ msgstr "Přijato"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Aktualizovat" msgstr "Aktualizovat"
@@ -1658,7 +1643,7 @@ msgstr "Čas začátku"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Stav" msgstr "Stav"
@@ -1686,7 +1671,7 @@ msgstr "Swap využití"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Přepnout motiv" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "Systém"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Rychlosti ventilátorů systému (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Propustnost {extraFsName}" msgstr "Propustnost {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Propustnost fondu {displayName}" msgstr "Propustnost fondu ZFS {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1834,7 +1819,7 @@ msgstr "Celkový odeslaný objem dat pro každé rozhraní"
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Celkový čas strávený čtením/zápisem (může přesáhnout 100 %)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
@@ -1912,7 +1897,6 @@ msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Typ" msgstr "Typ"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Univerzální token" msgstr "Univerzální token"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Neznámá" msgstr "Neznámá"
@@ -1961,7 +1945,7 @@ msgstr "Aktualizovat"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Aktualizováno" msgstr "Aktualizováno"
@@ -1984,20 +1968,20 @@ msgstr "Doba provozu"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Využití" msgstr "Využití"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Využití fondu {displayName}" msgstr "Využití fondu ZFS {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Využito" msgstr "Využito"
@@ -2074,7 +2058,7 @@ msgstr "Windows příkaz"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Windows příkaz"
msgid "Write" msgid "Write"
msgstr "Psát" msgstr "Psát"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Chyby zápisu" msgstr "Chyby zápisu"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n" "Language: da\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Danish\n" "Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -48,7 +48,7 @@ msgstr "{count, plural, one {{countString} minut} other {{countString} minutter}
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O" msgid "{diskName} I/O"
msgstr "" msgstr "{diskName} I/O"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
@@ -97,7 +97,7 @@ msgstr "5 minutter"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Handlinger" msgstr "Handlinger"
@@ -154,7 +154,7 @@ msgstr "Efter indstilling af miljøvariablerne skal du genstarte din Beszel-hub
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -196,7 +196,7 @@ msgstr "Er du sikker?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Automatisk kopiering kræver en sikker kontekst." msgstr "Automatisk kopiering kræver en sikker kontekst."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Tilgængelig" msgstr "Tilgængelig"
@@ -300,7 +300,7 @@ msgstr "Binær"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Boot state" msgid "Boot state"
@@ -309,7 +309,7 @@ msgstr "Opstartstilstand"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Funktioner" msgstr "Funktioner"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Kapacitet" msgstr "Kapacitet"
@@ -348,7 +348,7 @@ msgstr "Forsigtig - muligt tab af data"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -391,14 +391,14 @@ msgstr "Tjek din overvågningstjeneste"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Tjek din notifikationstjeneste" msgstr "Tjek din notifikationstjeneste"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Kontrolsumfejl" msgstr "Kontrolsumfejl"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Ryd" msgstr "Ryd"
@@ -411,7 +411,7 @@ msgstr "Klik på en container for at se mere information."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Klik på en enhed for at se flere oplysninger." msgstr "Klik på en enhed for at se flere oplysninger."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Klik på en pool for at se detaljer om vdev og datasæt." msgstr "Klik på en pool for at se detaljer om vdev og datasæt."
@@ -447,7 +447,7 @@ msgstr "Forbindelsen er nede"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container" msgid "Container"
msgstr "" msgstr "Container"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container Health" msgid "Container Health"
@@ -503,7 +503,7 @@ msgstr "Kopier navn"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Kopiér offentlig nøgle" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -530,7 +530,7 @@ msgstr "Kerne"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -542,7 +542,7 @@ msgstr "CPU-I/O-ventetid (IOWait)"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "CPU Peak" msgid "CPU Peak"
msgstr "" msgstr "CPU Peak"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "CPU Steal Time" msgid "CPU Steal Time"
@@ -661,7 +661,7 @@ msgstr "Aflader"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Disk" msgid "Disk"
msgstr "" msgstr "Disk"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
@@ -732,7 +732,7 @@ msgstr "Rediger {foo}"
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
msgid "Email" msgid "Email"
msgstr "" msgstr "Email"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Email notifications" msgid "Email notifications"
@@ -826,7 +826,7 @@ msgstr "Eksporter din nuværende systemkonfiguration."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Failed" msgid "Failed"
@@ -869,18 +869,18 @@ msgstr "Mislykkedes: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Blæsere" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
msgstr "" msgstr "Filter..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
@@ -888,7 +888,7 @@ msgstr "Fingeraftryk"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Firmware" msgid "Firmware"
msgstr "" msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -898,8 +898,8 @@ msgstr "For <0>{min}</0> {min, plural, one {minut} other {minutter}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Glemt adgangskode?" msgstr "Glemt adgangskode?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Ledig" msgstr "Ledig"
@@ -924,7 +924,7 @@ msgstr "Generelt"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Global" msgid "Global"
msgstr "" msgstr "Global"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "Gitter" msgstr "Gitter"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Sundhed" msgstr "Sundhed"
@@ -1091,7 +1091,7 @@ msgstr "Loginforsøg mislykkedes"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Logs" msgid "Logs"
msgstr "" msgstr "Logs"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table." msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
@@ -1144,9 +1144,9 @@ msgstr "Containeres hukommelsesforbrug"
#. Device model #. Device model
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Model" msgid "Model"
msgstr "" msgstr "Model"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Monteringspunkt" msgstr "Monteringspunkt"
@@ -1161,7 +1161,7 @@ msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Net" msgid "Net"
msgstr "" msgstr "Net"
#: src/components/routes/system/charts/network-charts.tsx #: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of containers" msgid "Network traffic of containers"
@@ -1185,7 +1185,7 @@ msgstr "Netværksenhed"
msgid "No" msgid "No"
msgstr "Nej" msgstr "Nej"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Ingen detaljerede data for denne pool." msgstr "Ingen detaljerede data for denne pool."
@@ -1212,7 +1212,7 @@ msgstr "Ingen S.M.A.R.T.-attributter tilgængelige for denne enhed."
msgid "No systems found." msgid "No systems found."
msgstr "Ingen systemer fundet." msgstr "Ingen systemer fundet."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Ingen" msgstr "Ingen"
@@ -1255,7 +1255,7 @@ msgstr "Engangsadgangskode"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Åbn menu" msgstr "Åbn menu"
@@ -1311,7 +1311,7 @@ msgstr "Tidligere"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Pause" msgid "Pause"
msgstr "" msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Paused" msgid "Paused"
@@ -1346,10 +1346,6 @@ msgstr ""
msgid "Persistence" msgid "Persistence"
msgstr "Vedholdenhed" msgstr "Vedholdenhed"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Fysisk enhedsplads. Den reelle brugbare kapacitet er ukendt. Advarsler om diskforbrug for poolen er deaktiveret."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Konfigurer <0>en SMTP server</0> for at sikre at alarmer bliver leveret." msgstr "Konfigurer <0>en SMTP server</0> for at sikre at alarmer bliver leveret."
@@ -1383,17 +1379,17 @@ msgstr "Se <0>dokumentationen</0> for instruktioner."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Log venligst ind på din konto" msgstr "Log venligst ind på din konto"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Poolstatus" msgstr "Poolstatus"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Poolforbrug" msgstr "Poolforbrug"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
@@ -1420,7 +1416,7 @@ msgstr "Proces startet"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Offentlig nøgle" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Kødybde"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Stille timer" msgstr "Stille timer"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Rå"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Råt forbrug af lagerpool {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Råt forbrug af lagerpool {displayName}"
msgid "Read" msgid "Read"
msgstr "Læs" msgstr "Læs"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Læsefejl" msgstr "Læsefejl"
@@ -1469,7 +1454,7 @@ msgstr "Modtaget"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Opdater" msgstr "Opdater"
@@ -1516,7 +1501,7 @@ msgstr "Genoptag"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label" msgctxt "Root disk label"
msgid "Root" msgid "Root"
msgstr "" msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
@@ -1658,7 +1643,7 @@ msgstr "Starttid"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Tilstand" msgstr "Tilstand"
@@ -1669,7 +1654,7 @@ msgstr "Tilstand"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
msgstr "" msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State" msgid "Sub State"
@@ -1686,7 +1671,7 @@ msgstr "Swap forbrug"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Skift tema" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1701,11 +1686,11 @@ msgstr "Skift tema"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "System" msgid "System"
msgstr "" msgstr "System"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Systemblæserhastigheder (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1713,7 +1698,7 @@ msgstr "Gennemsnitlig system belastning over tid"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Systemd Services" msgid "Systemd Services"
msgstr "" msgstr "Systemd Services"
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Systems" msgid "Systems"
@@ -1757,7 +1742,7 @@ msgstr "Temperaturer i systemsensorer"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "" msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat" msgid "Test heartbeat"
@@ -1787,9 +1772,9 @@ msgstr "Dette vil permanent slette alle poster fra databasen."
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Gennemløb af {extraFsName}" msgstr "Gennemløb af {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Gennemløb af lagerpool {displayName}" msgstr "Gennemløb for ZFS-pool {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1834,7 +1819,7 @@ msgstr "Samlet sendt data for hver interface"
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Samlet tid brugt på læsning/skrivning (kan overstige 100 %)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
@@ -1912,9 +1897,8 @@ msgstr "Udløser når brugen af en disk overstiger en tærskel"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "" msgstr "Type"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Unhealthy" msgid "Unhealthy"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Universalnøgle" msgstr "Universalnøgle"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Ukendt" msgstr "Ukendt"
@@ -1961,7 +1945,7 @@ msgstr "Opdater"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Opdateret" msgstr "Opdateret"
@@ -1984,20 +1968,20 @@ msgstr "Oppetid"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Forbrug" msgstr "Forbrug"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Forbrug af lagerpool {displayName}" msgstr "Forbrug af ZFS-pool {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Brugt" msgstr "Brugt"
@@ -2074,7 +2058,7 @@ msgstr "Windows-kommando"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Windows-kommando"
msgid "Write" msgid "Write"
msgstr "Skriv" msgstr "Skriv"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Skrivefejl" msgstr "Skrivefejl"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n" "Language: de\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 20:36\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: German\n" "Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -97,7 +97,7 @@ msgstr "5 Min"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
@@ -142,7 +142,7 @@ msgstr "Breite des Hauptlayouts anpassen"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "After" msgid "After"
@@ -154,7 +154,7 @@ msgstr "Starten Sie nach dem Festlegen der Umgebungsvariablen Ihren Beszel-Hub n
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -196,7 +196,7 @@ msgstr "Bist du sicher?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext." msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Verfügbar" msgstr "Verfügbar"
@@ -248,7 +248,7 @@ msgstr "Durchschnittliche Auslastung der GPU-Engines"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Backups" msgid "Backups"
msgstr "" msgstr "Backups"
#: src/components/routes/system/charts/network-charts.tsx #: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -300,7 +300,7 @@ msgstr "Binär"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Boot state" msgid "Boot state"
@@ -309,7 +309,7 @@ msgstr "Boot-Zustand"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Fähigkeiten" msgstr "Fähigkeiten"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Kapazität" msgstr "Kapazität"
@@ -348,7 +348,7 @@ msgstr "Vorsicht - potenzieller Datenverlust"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -391,14 +391,14 @@ msgstr "Überprüfen Sie Ihren Überwachungsdienst"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Überprüfe deinen Benachrichtigungsdienst" msgstr "Überprüfe deinen Benachrichtigungsdienst"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Prüfsummenfehler" msgstr "Prüfsummenfehler"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Löschen" msgstr "Löschen"
@@ -411,7 +411,7 @@ msgstr "Klicke auf einen Container, um weitere Informationen zu sehen."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Klicke auf ein Gerät, um weitere Informationen zu sehen." msgstr "Klicke auf ein Gerät, um weitere Informationen zu sehen."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Klicke auf einen Pool, um Details zu vdevs und Datensätzen anzuzeigen." msgstr "Klicke auf einen Pool, um Details zu vdevs und Datensätzen anzuzeigen."
@@ -447,7 +447,7 @@ msgstr "Verbindung unterbrochen"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container" msgid "Container"
msgstr "" msgstr "Container"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container Health" msgid "Container Health"
@@ -530,7 +530,7 @@ msgstr "Kern"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -826,7 +826,7 @@ msgstr "Exportiere die aktuelle Systemkonfiguration."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Failed" msgid "Failed"
@@ -875,12 +875,12 @@ msgstr "Lüfter"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
msgstr "" msgstr "Filter..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
@@ -898,8 +898,8 @@ msgstr "Für <0>{min}</0> {min, plural, one {Minute} other {Minuten}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Passwort vergessen?" msgstr "Passwort vergessen?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Frei" msgstr "Frei"
@@ -924,7 +924,7 @@ msgstr "Allgemein"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Global" msgid "Global"
msgstr "" msgstr "Global"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
@@ -948,13 +948,13 @@ msgid "Grid"
msgstr "Raster" msgstr "Raster"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Gesundheit" msgstr "Gesundheit"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -972,7 +972,7 @@ msgstr "Homebrew-Befehl"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Host / IP" msgid "Host / IP"
msgstr "" msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method" msgid "HTTP Method"
@@ -1009,7 +1009,7 @@ msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image" msgctxt "Docker image"
msgid "Image" msgid "Image"
msgstr "" msgstr "Image"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive" msgid "Inactive"
@@ -1146,7 +1146,7 @@ msgstr "Speichernutzung der Container"
msgid "Model" msgid "Model"
msgstr "Modell" msgstr "Modell"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Einhängepunkt" msgstr "Einhängepunkt"
@@ -1156,7 +1156,7 @@ msgstr "Einhängepunkt"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Name" msgid "Name"
msgstr "" msgstr "Name"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
@@ -1185,7 +1185,7 @@ msgstr "Netzwerkeinheit"
msgid "No" msgid "No"
msgstr "Nein" msgstr "Nein"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Für diesen Pool sind keine Detaildaten verfügbar." msgstr "Für diesen Pool sind keine Detaildaten verfügbar."
@@ -1212,7 +1212,7 @@ msgstr "Für dieses Gerät sind keine S.M.A.R.T.-Attribute verfügbar."
msgid "No systems found." msgid "No systems found."
msgstr "Keine Systeme gefunden." msgstr "Keine Systeme gefunden."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Keine" msgstr "Keine"
@@ -1255,7 +1255,7 @@ msgstr "Einmalpasswort"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Menü öffnen" msgstr "Menü öffnen"
@@ -1311,7 +1311,7 @@ msgstr "Vergangen"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Pause" msgid "Pause"
msgstr "" msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Paused" msgid "Paused"
@@ -1346,10 +1346,6 @@ msgstr "Dauerhaft"
msgid "Persistence" msgid "Persistence"
msgstr "Persistenz" msgstr "Persistenz"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Physischer Gerätespeicherplatz. Die tatsächlich nutzbare Kapazität ist unbekannt. Warnungen zur Pool-Festplattennutzung sind deaktiviert."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Bitte <0>konfiguriere einen SMTP-Server</0>, um sicherzustellen, dass Warnungen zugestellt werden." msgstr "Bitte <0>konfiguriere einen SMTP-Server</0>, um sicherzustellen, dass Warnungen zugestellt werden."
@@ -1383,22 +1379,22 @@ msgstr "In der <0>Dokumentation</0> findest du weitere Anweisungen."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Bitte melde dich bei deinem Konto an" msgstr "Bitte melde dich bei deinem Konto an"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Pool-Zustand" msgstr "Pool-Zustand"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Pool-Auslastung" msgstr "Pool-Auslastung"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
msgid "Ports" msgid "Ports"
msgstr "" msgstr "Ports"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1436,21 +1432,10 @@ msgstr "Warteschlangentiefe"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Ruhezeiten" msgstr "Ruhezeiten"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Roh"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Rohauslastung des Pools {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Rohauslastung des Pools {displayName}"
msgid "Read" msgid "Read"
msgstr "Lesen" msgstr "Lesen"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Lesefehler" msgstr "Lesefehler"
@@ -1469,7 +1454,7 @@ msgstr "Empfangen"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Aktualisieren" msgstr "Aktualisieren"
@@ -1516,7 +1501,7 @@ msgstr "Fortsetzen"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label" msgctxt "Root disk label"
msgid "Root" msgid "Root"
msgstr "" msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
@@ -1658,7 +1643,7 @@ msgstr "Startzeit"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Status" msgstr "Status"
@@ -1669,7 +1654,7 @@ msgstr "Status"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
msgstr "" msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State" msgid "Sub State"
@@ -1701,11 +1686,11 @@ msgstr "Design wechseln"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "System" msgid "System"
msgstr "" msgstr "System"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Systemlüftergeschwindigkeiten (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1730,7 +1715,7 @@ msgstr "Tabelle"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option" msgctxt "Tabs system layout option"
msgid "Tabs" msgid "Tabs"
msgstr "" msgstr "Tabs"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -1757,7 +1742,7 @@ msgstr "Temperaturen der Systemsensoren"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>" msgid "Test <0>URL</0>"
msgstr "" msgstr "Test <0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat" msgid "Test heartbeat"
@@ -1787,9 +1772,9 @@ msgstr "Dadurch werden alle ausgewählten Datensätze dauerhaft aus der Datenban
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Durchsatz von {extraFsName}" msgstr "Durchsatz von {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Durchsatz des Pools {displayName}" msgstr "Durchsatz des ZFS-Pools {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1802,7 +1787,7 @@ msgstr "An E-Mail(s)"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1912,7 +1897,6 @@ msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert übersc
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Typ" msgstr "Typ"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Universeller Token" msgstr "Universeller Token"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Unbekannt" msgstr "Unbekannt"
@@ -1961,7 +1945,7 @@ msgstr "Aktualisieren"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Aktualisiert" msgstr "Aktualisiert"
@@ -1984,20 +1968,20 @@ msgstr "Betriebszeit"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Nutzung" msgstr "Nutzung"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Auslastung des Pools {displayName}" msgstr "Auslastung des ZFS-Pools {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Verwendet" msgstr "Verwendet"
@@ -2074,7 +2058,7 @@ msgstr "Windows-Befehl"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Windows-Befehl"
msgid "Write" msgid "Write"
msgstr "Schreiben" msgstr "Schreiben"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Schreibfehler" msgstr "Schreibfehler"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: el\n" "Language: el\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Greek\n" "Language-Team: Greek\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,7 +52,7 @@ msgstr "I/O {diskName}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "" msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -97,7 +97,7 @@ msgstr "5 λ"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Ενέργειες" msgstr "Ενέργειες"
@@ -196,7 +196,7 @@ msgstr "Είστε βέβαιοι;"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Η αυτόματη αντιγραφή απαιτεί ασφαλές περιβάλλον." msgstr "Η αυτόματη αντιγραφή απαιτεί ασφαλές περιβάλλον."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Διαθέσιμο" msgstr "Διαθέσιμο"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Δυνατότητες" msgstr "Δυνατότητες"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Χωρητικότητα" msgstr "Χωρητικότητα"
@@ -391,14 +391,14 @@ msgstr "Ελέγξτε την υπηρεσία παρακολούθησής σα
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Ελέγξτε την υπηρεσία ειδοποιήσεών σας" msgstr "Ελέγξτε την υπηρεσία ειδοποιήσεών σας"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Σφάλματα αθροίσματος ελέγχου" msgstr "Σφάλματα αθροίσματος ελέγχου"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Εκκαθάριση" msgstr "Εκκαθάριση"
@@ -411,7 +411,7 @@ msgstr "Κάντε κλικ σε ένα κοντέινερ για να δείτ
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Κάντε κλικ σε μια συσκευή για να δείτε περισσότερες πληροφορίες." msgstr "Κάντε κλικ σε μια συσκευή για να δείτε περισσότερες πληροφορίες."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Κάντε κλικ σε ένα pool για να δείτε λεπτομέρειες για τα vdev και τα σύνολα δεδομένων." msgstr "Κάντε κλικ σε ένα pool για να δείτε λεπτομέρειες για τα vdev και τα σύνολα δεδομένων."
@@ -447,11 +447,11 @@ msgstr "Η σύνδεση είναι εκτός λειτουργίας"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container" msgid "Container"
msgstr "Κοντέινερ" msgstr ""
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Container Health" msgid "Container Health"
msgstr "Υγεία κοντέινερ" msgstr ""
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "Containers" msgid "Containers"
@@ -530,7 +530,7 @@ msgstr "Βασικά"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -732,7 +732,7 @@ msgstr "Επεξεργασία {foo}"
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
msgid "Email" msgid "Email"
msgstr "" msgstr "Email"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Email notifications" msgid "Email notifications"
@@ -875,8 +875,8 @@ msgstr "Ανεμιστήρες"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "Για <0>{min}</0> {min, plural, one {λεπτό} other {λεπτά}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Ξεχάσατε τον κωδικό πρόσβασης;" msgstr "Ξεχάσατε τον κωδικό πρόσβασης;"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Ελεύθερο" msgstr "Ελεύθερο"
@@ -928,7 +928,7 @@ msgstr "Καθολικό"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -948,13 +948,13 @@ msgid "Grid"
msgstr "Πλέγμα" msgstr "Πλέγμα"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Υγεία" msgstr "Υγεία"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -1146,7 +1146,7 @@ msgstr "Χρήση μνήμης των κοντέινερ"
msgid "Model" msgid "Model"
msgstr "Μοντέλο" msgstr "Μοντέλο"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Σημείο προσάρτησης" msgstr "Σημείο προσάρτησης"
@@ -1185,7 +1185,7 @@ msgstr "Μονάδα δικτύου"
msgid "No" msgid "No"
msgstr "Όχι" msgstr "Όχι"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Δεν υπάρχουν λεπτομερή δεδομένα για αυτό το pool." msgstr "Δεν υπάρχουν λεπτομερή δεδομένα για αυτό το pool."
@@ -1212,7 +1212,7 @@ msgstr "Δεν υπάρχουν διαθέσιμα χαρακτηριστικά
msgid "No systems found." msgid "No systems found."
msgstr "Δεν βρέθηκαν συστήματα." msgstr "Δεν βρέθηκαν συστήματα."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Κανένα" msgstr "Κανένα"
@@ -1255,7 +1255,7 @@ msgstr "Κωδικός μίας χρήσης"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Άνοιγμα μενού" msgstr "Άνοιγμα μενού"
@@ -1346,10 +1346,6 @@ msgstr "Μόνιμο"
msgid "Persistence" msgid "Persistence"
msgstr "Διατήρηση" msgstr "Διατήρηση"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Φυσικός χώρος συσκευής. Η πραγματική ωφέλιμη χωρητικότητα είναι άγνωστη. Οι ειδοποιήσεις χρήσης δίσκων του pool είναι απενεργοποιημένες."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Παρακαλώ <0>ρυθμίστε έναν διακομιστή SMTP</0> για να διασφαλίσετε την παράδοση των ειδοποιήσεων." msgstr "Παρακαλώ <0>ρυθμίστε έναν διακομιστή SMTP</0> για να διασφαλίσετε την παράδοση των ειδοποιήσεων."
@@ -1383,11 +1379,11 @@ msgstr "Ανατρέξτε στην <0>τεκμηρίωση</0> για οδηγ
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Συνδεθείτε στον λογαριασμό σας" msgstr "Συνδεθείτε στον λογαριασμό σας"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Κατάσταση pool" msgstr "Κατάσταση pool"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Χρήση pool" msgstr "Χρήση pool"
@@ -1436,21 +1432,10 @@ msgstr "Βάθος ουράς"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Ώρες σίγασης" msgstr "Ώρες σίγασης"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Ακατέργαστο"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Ακατέργαστη χρήση του pool αποθήκευσης {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Ακατέργαστη χρήση του pool αποθήκευσης {di
msgid "Read" msgid "Read"
msgstr "Ανάγνωση" msgstr "Ανάγνωση"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Σφάλματα ανάγνωσης" msgstr "Σφάλματα ανάγνωσης"
@@ -1469,7 +1454,7 @@ msgstr "Λήφθηκαν"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Ανανέωση" msgstr "Ανανέωση"
@@ -1658,7 +1643,7 @@ msgstr "Ώρα έναρξης"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Κατάσταση" msgstr "Κατάσταση"
@@ -1787,9 +1772,9 @@ msgstr "Αυτό θα διαγράψει οριστικά όλες τις επι
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Ρυθμός μεταφοράς του {extraFsName}" msgstr "Ρυθμός μεταφοράς του {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Ρυθμός μεταφοράς του pool αποθήκευσης {displayName}" msgstr "Ρυθμός διαμεταγωγής του ZFS pool {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1912,13 +1897,12 @@ msgstr "Ενεργοποιείται όταν η χρήση οποιουδήπο
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Τύπος" msgstr "Τύπος"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Unhealthy" msgid "Unhealthy"
msgstr "Μη υγιές" msgstr ""
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Unit file" msgid "Unit file"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Καθολικό διακριτικό" msgstr "Καθολικό διακριτικό"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Άγνωστο" msgstr "Άγνωστο"
@@ -1961,7 +1945,7 @@ msgstr "Ενημέρωση"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Ενημερώθηκε" msgstr "Ενημερώθηκε"
@@ -1984,20 +1968,20 @@ msgstr "Χρόνος λειτουργίας"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Χρήση" msgstr "Χρήση"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Χρήση του pool αποθήκευσης {displayName}" msgstr "Χρήση του ZFS pool {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Χρησιμοποιείται" msgstr "Χρησιμοποιείται"
@@ -2074,7 +2058,7 @@ msgstr "Εντολή Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Εντολή Windows"
msgid "Write" msgid "Write"
msgstr "Εγγραφή" msgstr "Εγγραφή"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Σφάλματα εγγραφής" msgstr "Σφάλματα εγγραφής"

View File

@@ -92,7 +92,7 @@ msgstr "5 min"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Actions" msgstr "Actions"
@@ -191,7 +191,7 @@ msgstr "Are you sure?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Automatic copy requires a secure context." msgstr "Automatic copy requires a secure context."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Available" msgstr "Available"
@@ -333,7 +333,7 @@ msgid "Capabilities"
msgstr "Capabilities" msgstr "Capabilities"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Capacity" msgstr "Capacity"
@@ -386,14 +386,14 @@ msgstr "Check your monitoring service"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Check your notification service" msgstr "Check your notification service"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Checksum errors" msgstr "Checksum errors"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Clear" msgstr "Clear"
@@ -406,7 +406,7 @@ msgstr "Click on a container to view more information."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Click on a device to view more information." msgstr "Click on a device to view more information."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Click on a pool to view vdev and dataset details." msgstr "Click on a pool to view vdev and dataset details."
@@ -870,8 +870,8 @@ msgstr "Fans"
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -893,8 +893,8 @@ msgstr "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Forgot password?" msgstr "Forgot password?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Free" msgstr "Free"
@@ -943,7 +943,7 @@ msgid "Grid"
msgstr "Grid" msgstr "Grid"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Health" msgstr "Health"
@@ -1141,7 +1141,7 @@ msgstr "Memory usage of containers"
msgid "Model" msgid "Model"
msgstr "Model" msgstr "Model"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Mountpoint" msgstr "Mountpoint"
@@ -1180,7 +1180,7 @@ msgstr "Network unit"
msgid "No" msgid "No"
msgstr "No" msgstr "No"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "No detail data for this pool." msgstr "No detail data for this pool."
@@ -1207,7 +1207,7 @@ msgstr "No S.M.A.R.T. attributes available for this device."
msgid "No systems found." msgid "No systems found."
msgstr "No systems found." msgstr "No systems found."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "None" msgstr "None"
@@ -1250,7 +1250,7 @@ msgstr "One-time password"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Open menu" msgstr "Open menu"
@@ -1341,10 +1341,6 @@ msgstr "Permanent"
msgid "Persistence" msgid "Persistence"
msgstr "Persistence" msgstr "Persistence"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -1378,11 +1374,11 @@ msgstr "Please see <0>the documentation</0> for instructions."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Please sign in to your account" msgstr "Please sign in to your account"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Pool Health" msgstr "Pool Health"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Pool Usage" msgstr "Pool Usage"
@@ -1431,21 +1427,10 @@ msgstr "Queue Depth"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Quiet Hours" msgstr "Quiet Hours"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Raw"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Raw usage of storage pool {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1453,7 +1438,7 @@ msgstr "Raw usage of storage pool {displayName}"
msgid "Read" msgid "Read"
msgstr "Read" msgstr "Read"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Read errors" msgstr "Read errors"
@@ -1464,7 +1449,7 @@ msgstr "Received"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Refresh" msgstr "Refresh"
@@ -1653,7 +1638,7 @@ msgstr "Start Time"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "State" msgstr "State"
@@ -1782,9 +1767,9 @@ msgstr "This will permanently delete all selected records from the database."
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}" msgstr "Throughput of {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Throughput of storage pool {displayName}" msgstr "Throughput of ZFS pool {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1907,7 +1892,6 @@ msgstr "Triggers when usage of any disk exceeds a threshold"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Type" msgstr "Type"
@@ -1930,7 +1914,7 @@ msgid "Universal token"
msgstr "Universal token" msgstr "Universal token"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Unknown" msgstr "Unknown"
@@ -1956,7 +1940,7 @@ msgstr "Update"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Updated" msgstr "Updated"
@@ -1979,20 +1963,20 @@ msgstr "Uptime"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Usage" msgstr "Usage"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Usage of storage pool {displayName}" msgstr "Usage of ZFS pool {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Used" msgstr "Used"
@@ -2069,7 +2053,7 @@ msgstr "Windows command"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2077,7 +2061,7 @@ msgstr "Windows command"
msgid "Write" msgid "Write"
msgstr "Write" msgstr "Write"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Write errors" msgstr "Write errors"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n" "Language: es\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Spanish\n" "Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -61,7 +61,7 @@ msgstr "1 hora"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 minute" msgid "1 minute"
@@ -78,7 +78,7 @@ msgstr "12 horas"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -91,13 +91,13 @@ msgstr "30 días"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 min"
#. Table column #. Table column
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Acciones" msgstr "Acciones"
@@ -196,7 +196,7 @@ msgstr "¿Estás seguro?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "La copia automática requiere un contexto seguro." msgstr "La copia automática requiere un contexto seguro."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Disponible" msgstr "Disponible"
@@ -258,7 +258,7 @@ msgstr "Ancho de banda"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "" msgstr "Bat"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Capacidades" msgstr "Capacidades"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Capacidad" msgstr "Capacidad"
@@ -348,7 +348,7 @@ msgstr "Precaución - posible pérdida de datos"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -391,14 +391,14 @@ msgstr "Compruebe su servicio de monitorización"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Verifica tu servicio de notificaciones" msgstr "Verifica tu servicio de notificaciones"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Errores de suma de comprobación" msgstr "Errores de suma de comprobación"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Limpiar" msgstr "Limpiar"
@@ -411,7 +411,7 @@ msgstr "Haz clic en un contenedor para ver más información."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Haz clic en un dispositivo para ver más información." msgstr "Haz clic en un dispositivo para ver más información."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Haz clic en un pool para ver los detalles de los vdev y los conjuntos de datos." msgstr "Haz clic en un pool para ver los detalles de los vdev y los conjuntos de datos."
@@ -503,7 +503,7 @@ msgstr "Copiar nombre"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Copiar clave pública" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -530,7 +530,7 @@ msgstr "Núcleo"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -783,7 +783,7 @@ msgstr "Efímero"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Error" msgid "Error"
msgstr "" msgstr "Error"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Example:" msgid "Example:"
@@ -826,7 +826,7 @@ msgstr "Exporta la configuración actual de sus sistemas."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Failed" msgid "Failed"
@@ -869,14 +869,14 @@ msgstr "Fallidos: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Ventiladores" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -888,7 +888,7 @@ msgstr "Huella dactilar"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Firmware" msgid "Firmware"
msgstr "" msgstr "Firmware"
#: src/components/alerts/alerts-sheet.tsx #: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}" msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -898,8 +898,8 @@ msgstr "Por <0>{min}</0> {min, plural, one {minuto} other {minutos}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "¿Olvidaste tu contraseña?" msgstr "¿Olvidaste tu contraseña?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Libre" msgstr "Libre"
@@ -920,11 +920,11 @@ msgstr "Llena"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "General" msgid "General"
msgstr "" msgstr "General"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Global" msgid "Global"
msgstr "" msgstr "Global"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "Cuadrícula" msgstr "Cuadrícula"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Estado" msgstr "Estado"
@@ -1146,7 +1146,7 @@ msgstr "Uso de memoria de los contenedores"
msgid "Model" msgid "Model"
msgstr "Modelo" msgstr "Modelo"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Punto de montaje" msgstr "Punto de montaje"
@@ -1183,9 +1183,9 @@ msgstr "Unidad de red"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "No" msgid "No"
msgstr "" msgstr "No"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "No hay datos detallados para este pool." msgstr "No hay datos detallados para este pool."
@@ -1212,7 +1212,7 @@ msgstr "No hay atributos S.M.A.R.T. disponibles para este dispositivo."
msgid "No systems found." msgid "No systems found."
msgstr "No se encontraron sistemas." msgstr "No se encontraron sistemas."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Ninguno" msgstr "Ninguno"
@@ -1255,7 +1255,7 @@ msgstr "Contraseña de un solo uso"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Abrir menú" msgstr "Abrir menú"
@@ -1346,10 +1346,6 @@ msgstr "Permanente"
msgid "Persistence" msgid "Persistence"
msgstr "Persistencia" msgstr "Persistencia"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Espacio físico del dispositivo. La capacidad útil real se desconoce. Las alertas de uso de disco del pool están desactivadas."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Por favor, <0>configura un servidor SMTP</0> para asegurar que las alertas sean entregadas." msgstr "Por favor, <0>configura un servidor SMTP</0> para asegurar que las alertas sean entregadas."
@@ -1383,11 +1379,11 @@ msgstr "Por favor, consulta <0>la documentación</0> para obtener instrucciones.
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Por favor, inicia sesión en tu cuenta" msgstr "Por favor, inicia sesión en tu cuenta"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Estado del pool" msgstr "Estado del pool"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Uso del pool" msgstr "Uso del pool"
@@ -1420,7 +1416,7 @@ msgstr "Proceso iniciado"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Clave pública" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Profundidad de cola"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Horas de silencio" msgstr "Horas de silencio"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Bruto"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Uso bruto del pool de almacenamiento {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Uso bruto del pool de almacenamiento {displayName}"
msgid "Read" msgid "Read"
msgstr "Lectura" msgstr "Lectura"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Errores de lectura" msgstr "Errores de lectura"
@@ -1469,7 +1454,7 @@ msgstr "Recibido"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Actualizar" msgstr "Actualizar"
@@ -1658,7 +1643,7 @@ msgstr "Hora de inicio"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Estado" msgstr "Estado"
@@ -1686,7 +1671,7 @@ msgstr "Uso de swap"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Cambiar tema" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "Sistema"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Velocidades de los ventiladores del sistema (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "Esto eliminará permanentemente todos los registros seleccionados de la
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Rendimiento de {extraFsName}" msgstr "Rendimiento de {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Rendimiento del pool de almacenamiento {displayName}" msgstr "Rendimiento del pool ZFS {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1802,7 +1787,7 @@ msgstr "A correo(s)"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1821,7 +1806,7 @@ msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conex
#: src/components/ui/chart.tsx #: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx #: src/components/ui/chart.tsx
msgid "Total" msgid "Total"
msgstr "" msgstr "Total"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface" msgid "Total data received for each interface"
@@ -1834,12 +1819,12 @@ msgstr "Datos totales enviados por cada interfaz"
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Tiempo total dedicado a lectura/escritura (puede superar el 100 %)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}" msgid "Total: {0}"
msgstr "" msgstr "Total: {0}"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Triggered by" msgid "Triggered by"
@@ -1912,7 +1897,6 @@ msgstr "Se activa cuando el uso de cualquier disco supera un umbral"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Tipo" msgstr "Tipo"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Token universal" msgstr "Token universal"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Desconocida" msgstr "Desconocida"
@@ -1961,7 +1945,7 @@ msgstr "Actualizar"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Actualizado" msgstr "Actualizado"
@@ -1984,20 +1968,20 @@ msgstr "Tiempo de actividad"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Uso" msgstr "Uso"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Uso del pool de almacenamiento {displayName}" msgstr "Uso del pool ZFS {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Usado" msgstr "Usado"
@@ -2074,7 +2058,7 @@ msgstr "Comando Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Comando Windows"
msgid "Write" msgid "Write"
msgstr "Escritura" msgstr "Escritura"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Errores de escritura" msgstr "Errores de escritura"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fa\n" "Language: fa\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Persian\n" "Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -97,7 +97,7 @@ msgstr "۵ دقیقه"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "عملیات" msgstr "عملیات"
@@ -196,7 +196,7 @@ msgstr "آیا مطمئن هستید؟"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "کپی خودکار نیاز به یک زمینه امن دارد." msgstr "کپی خودکار نیاز به یک زمینه امن دارد."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "در دسترس" msgstr "در دسترس"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "قابلیت‌ها" msgstr "قابلیت‌ها"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "ظرفیت" msgstr "ظرفیت"
@@ -391,14 +391,14 @@ msgstr "سرویس نظارتی خود را بررسی کنید"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید" msgstr "سرویس اطلاع‌رسانی خود را بررسی کنید"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "خطاهای مجموع بررسی" msgstr "خطاهای مجموع بررسی"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "پاک کردن" msgstr "پاک کردن"
@@ -411,7 +411,7 @@ msgstr "برای مشاهده اطلاعات بیشتر روی کانتینر ک
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "برای مشاهده اطلاعات بیشتر روی دستگاه کلیک کنید." msgstr "برای مشاهده اطلاعات بیشتر روی دستگاه کلیک کنید."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "برای مشاهده جزئیات vdev و مجموعه‌داده‌ها روی یک استخر کلیک کنید." msgstr "برای مشاهده جزئیات vdev و مجموعه‌داده‌ها روی یک استخر کلیک کنید."
@@ -503,7 +503,7 @@ msgstr "کپی نام"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "کپی کردن کلید عمومی" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -869,14 +869,14 @@ msgstr "ناموفق: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "فن‌ها" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "برای <0>{min}</0> {min, plural, one {دقیقه} other {دقیقه}}
msgid "Forgot password?" msgid "Forgot password?"
msgstr "رمز عبور را فراموش کرده‌اید؟" msgstr "رمز عبور را فراموش کرده‌اید؟"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "آزاد" msgstr "آزاد"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "جدول" msgstr "جدول"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "سلامتی" msgstr "سلامتی"
@@ -1146,7 +1146,7 @@ msgstr "میزان استفاده حافظه کانتینرها"
msgid "Model" msgid "Model"
msgstr "مدل" msgstr "مدل"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "نقطه اتصال" msgstr "نقطه اتصال"
@@ -1185,7 +1185,7 @@ msgstr "واحد شبکه"
msgid "No" msgid "No"
msgstr "خیر" msgstr "خیر"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "دادهٔ جزئی برای این استخر موجود نیست." msgstr "دادهٔ جزئی برای این استخر موجود نیست."
@@ -1212,7 +1212,7 @@ msgstr "هیچ ویژگی S.M.A.R.T برای این دستگاه موجود نی
msgid "No systems found." msgid "No systems found."
msgstr "هیچ سیستمی یافت نشد." msgstr "هیچ سیستمی یافت نشد."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "هیچ‌کدام" msgstr "هیچ‌کدام"
@@ -1255,7 +1255,7 @@ msgstr "رمز عبور یک‌بار مصرف"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "باز کردن منو" msgstr "باز کردن منو"
@@ -1346,10 +1346,6 @@ msgstr "دائمی"
msgid "Persistence" msgid "Persistence"
msgstr "ماندگاری" msgstr "ماندگاری"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "فضای فیزیکی دستگاه. ظرفیت واقعی قابل استفاده نامشخص است. هشدارهای استفاده از دیسک استخر غیرفعال هستند."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "لطفاً برای اطمینان از تحویل هشدارها، یک <0>سرور SMTP پیکربندی کنید</0>." msgstr "لطفاً برای اطمینان از تحویل هشدارها، یک <0>سرور SMTP پیکربندی کنید</0>."
@@ -1383,11 +1379,11 @@ msgstr "لطفاً برای دستورالعمل‌ها به <0>مستندات</
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "لطفاً به حساب کاربری خود وارد شوید" msgstr "لطفاً به حساب کاربری خود وارد شوید"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "سلامت استخر" msgstr "سلامت استخر"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "استفاده از استخر" msgstr "استفاده از استخر"
@@ -1420,7 +1416,7 @@ msgstr "فرآیند شروع شد"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "کلید عمومی" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "عمق صف"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "ساعات آرام" msgstr "ساعات آرام"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "خام"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "استفاده خام از استخر ذخیره‌سازی {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "استفاده خام از استخر ذخیره‌سازی {displayName
msgid "Read" msgid "Read"
msgstr "خواندن" msgstr "خواندن"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "خطاهای خواندن" msgstr "خطاهای خواندن"
@@ -1469,7 +1454,7 @@ msgstr "دریافت شد"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "تازه‌سازی" msgstr "تازه‌سازی"
@@ -1658,7 +1643,7 @@ msgstr "زمان شروع"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "وضعیت" msgstr "وضعیت"
@@ -1686,7 +1671,7 @@ msgstr "میزان استفاده از Swap"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "تغییر تم" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "سیستم"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "سرعت فن‌های سیستم (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "این کار تمام رکوردهای انتخاب شده را برا
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "توان عملیاتی {extraFsName}" msgstr "توان عملیاتی {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "توان عملیاتی استخر ذخیره‌سازی {displayName}" msgstr "توان عملیاتی استخر ZFS ‏{poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1834,7 +1819,7 @@ msgstr "داده‌های کل ارسال شده برای هر رابط"
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "کل زمان صرف‌شده برای خواندن/نوشتن (ممکن است از ۱۰۰٪ بیشتر شود)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
@@ -1912,7 +1897,6 @@ msgstr "هنگامی که استفاده از هر دیسکی از یک آستا
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "نوع" msgstr "نوع"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "توکن جهانی" msgstr "توکن جهانی"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "ناشناخته" msgstr "ناشناخته"
@@ -1961,7 +1945,7 @@ msgstr "به‌روزرسانی"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "به‌روزرسانی شد" msgstr "به‌روزرسانی شد"
@@ -1984,20 +1968,20 @@ msgstr "آپتایم"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "میزان استفاده" msgstr "میزان استفاده"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "استفاده از استخر ذخیره‌سازی {displayName}" msgstr "استفاده از استخر ZFS ‏{poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "استفاده شده" msgstr "استفاده شده"
@@ -2074,7 +2058,7 @@ msgstr "دستور Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "دستور Windows"
msgid "Write" msgid "Write"
msgstr "نوشتن" msgstr "نوشتن"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "خطاهای نوشتن" msgstr "خطاهای نوشتن"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n" "Language: fr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:42\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: French\n" "Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -52,7 +52,7 @@ msgstr "E/S {diskName}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}" msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "" msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 hour" msgid "1 hour"
@@ -61,11 +61,11 @@ msgstr "1 heure"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min" msgid "1 min"
msgstr "" msgstr "1 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 minute" msgid "1 minute"
msgstr "" msgstr "1 minute"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "1 week" msgid "1 week"
@@ -78,7 +78,7 @@ msgstr "12 heures"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min" msgid "15 min"
msgstr "" msgstr "15 min"
#: src/lib/utils.ts #: src/lib/utils.ts
msgid "24 hours" msgid "24 hours"
@@ -91,23 +91,23 @@ msgstr "30 jours"
#. Load average #. Load average
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min" msgid "5 min"
msgstr "" msgstr "5 min"
#. Table column #. Table column
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "" msgstr "Actions"
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Active" msgid "Active"
msgstr "" msgstr "Active"
#: src/components/active-alerts.tsx #: src/components/active-alerts.tsx
msgid "Active Alerts" msgid "Active Alerts"
@@ -142,7 +142,7 @@ msgstr "Ajuster la largeur de la mise en page principale"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "After" msgid "After"
@@ -154,7 +154,7 @@ msgstr "Après avoir défini les variables d'environnement, redémarrez votre hu
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -196,7 +196,7 @@ msgstr "Êtes-vous sûr ?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "La copie automatique nécessite un contexte sécurisé." msgstr "La copie automatique nécessite un contexte sécurisé."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Disponible" msgstr "Disponible"
@@ -258,7 +258,7 @@ msgstr "Bande passante"
#. Battery label in systems table header #. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Bat" msgid "Bat"
msgstr "" msgstr "Bat"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
@@ -300,7 +300,7 @@ msgstr "Binaire"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)" msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "" msgstr "Bits (Kbps, Mbps, Gbps)"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Boot state" msgid "Boot state"
@@ -309,7 +309,7 @@ msgstr "État de démarrage"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)" msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "" msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers" msgid "Cache / Buffers"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Capacités" msgstr "Capacités"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Capacité" msgstr "Capacité"
@@ -348,7 +348,7 @@ msgstr "Attention - perte de données potentielle"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -360,7 +360,7 @@ msgstr "Modifier les options générales de l'application."
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge" msgid "Charge"
msgstr "" msgstr "Charge"
#. Context: Battery state #. Context: Battery state
#: src/lib/i18n.ts #: src/lib/i18n.ts
@@ -391,14 +391,14 @@ msgstr "Vérifiez votre service de surveillance"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Vérifiez votre service de notification" msgstr "Vérifiez votre service de notification"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Erreurs de somme de contrôle" msgstr "Erreurs de somme de contrôle"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Effacer" msgstr "Effacer"
@@ -411,7 +411,7 @@ msgstr "Cliquez sur un conteneur pour voir plus d'informations."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Cliquez sur un appareil pour voir plus d'informations." msgstr "Cliquez sur un appareil pour voir plus d'informations."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Cliquez sur un pool pour afficher les détails des vdev et des jeux de données." msgstr "Cliquez sur un pool pour afficher les détails des vdev et des jeux de données."
@@ -503,7 +503,7 @@ msgstr "Copier le nom"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Copier la clé publique" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -530,7 +530,7 @@ msgstr "Cœur"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -614,7 +614,7 @@ msgstr "État actuel"
#. Power Cycles #. Power Cycles
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Cycles" msgid "Cycles"
msgstr "" msgstr "Cycles"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
@@ -643,7 +643,7 @@ msgstr "Supprimer l'empreinte"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Description" msgid "Description"
msgstr "" msgstr "Description"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
msgid "Detail" msgid "Detail"
@@ -696,7 +696,7 @@ msgstr "Entrée/Sortie réseau Docker"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Documentation" msgid "Documentation"
msgstr "" msgstr "Documentation"
#. Context: System is down #. Context: System is down
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -732,7 +732,7 @@ msgstr "Modifier {foo}"
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
msgid "Email" msgid "Email"
msgstr "" msgstr "Email"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Email notifications" msgid "Email notifications"
@@ -826,7 +826,7 @@ msgstr "Exportez la configuration actuelle de vos systèmes."
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)" msgid "Fahrenheit (°F)"
msgstr "" msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Failed" msgid "Failed"
@@ -869,14 +869,14 @@ msgstr "Échec : {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Ventilateurs" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "Pendant <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Mot de passe oublié ?" msgstr "Mot de passe oublié ?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Espace libre" msgstr "Espace libre"
@@ -924,11 +924,11 @@ msgstr "Général"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Global" msgid "Global"
msgstr "" msgstr "Global"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "Grille" msgstr "Grille"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Santé" msgstr "Santé"
@@ -1009,7 +1009,7 @@ msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image" msgctxt "Docker image"
msgid "Image" msgid "Image"
msgstr "" msgstr "Image"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive" msgid "Inactive"
@@ -1113,7 +1113,7 @@ msgstr "Guide pour une installation manuelle"
#. Chart select field. Please try to keep this short. #. Chart select field. Please try to keep this short.
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
msgid "Max 1 min" msgid "Max 1 min"
msgstr "" msgstr "Max 1 min"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
@@ -1146,7 +1146,7 @@ msgstr "Utilisation de la mémoire des conteneurs"
msgid "Model" msgid "Model"
msgstr "Modèle" msgstr "Modèle"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Point de montage" msgstr "Point de montage"
@@ -1185,7 +1185,7 @@ msgstr "Unité réseau"
msgid "No" msgid "No"
msgstr "Non" msgstr "Non"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Aucune donnée détaillée disponible pour ce pool." msgstr "Aucune donnée détaillée disponible pour ce pool."
@@ -1212,7 +1212,7 @@ msgstr "Aucun attribut S.M.A.R.T. disponible pour cet appareil."
msgid "No systems found." msgid "No systems found."
msgstr "Aucun système trouvé." msgstr "Aucun système trouvé."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Aucun" msgstr "Aucun"
@@ -1220,7 +1220,7 @@ msgstr "Aucun"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Notifications" msgid "Notifications"
msgstr "" msgstr "Notifications"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Notifications may include recent container log excerpts." msgid "Notifications may include recent container log excerpts."
@@ -1255,7 +1255,7 @@ msgstr "Mot de passe à usage unique"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Ouvrir le menu" msgstr "Ouvrir le menu"
@@ -1276,7 +1276,7 @@ msgstr "Écraser les alertes existantes"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
msgid "Page" msgid "Page"
msgstr "" msgstr "Page"
#. placeholder {0}: table.getState().pagination.pageIndex + 1 #. placeholder {0}: table.getState().pagination.pageIndex + 1
#. placeholder {1}: table.getPageCount() #. placeholder {1}: table.getPageCount()
@@ -1311,7 +1311,7 @@ msgstr "Passé"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Pause" msgid "Pause"
msgstr "" msgstr "Pause"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Paused" msgid "Paused"
@@ -1340,16 +1340,12 @@ msgstr "Pourcentage de temps passé dans chaque état"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent" msgid "Permanent"
msgstr "" msgstr "Permanent"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence" msgid "Persistence"
msgstr "Persistance" msgstr "Persistance"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Espace physique de l'appareil. La capacité réellement utilisable est inconnue. Les alertes d'utilisation des disques du pool sont désactivées."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Veuillez <0>configurer un serveur SMTP</0> pour garantir la livraison des alertes." msgstr "Veuillez <0>configurer un serveur SMTP</0> pour garantir la livraison des alertes."
@@ -1383,22 +1379,22 @@ msgstr "Veuillez consulter <0>la documentation</0> pour les instructions."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Veuillez vous connecter à votre compte" msgstr "Veuillez vous connecter à votre compte"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "État du pool" msgstr "État du pool"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Utilisation du pool" msgstr "Utilisation du pool"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
msgid "Ports" msgid "Ports"
msgstr "" msgstr "Ports"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1420,7 +1416,7 @@ msgstr "Processus démarré"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Clé publique" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Profondeur de file d'attente"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Heures calmes" msgstr "Heures calmes"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Brut"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Utilisation brute du pool de stockage {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Utilisation brute du pool de stockage {displayName}"
msgid "Read" msgid "Read"
msgstr "Lecture" msgstr "Lecture"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Erreurs de lecture" msgstr "Erreurs de lecture"
@@ -1469,7 +1454,7 @@ msgstr "Reçu"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Actualiser" msgstr "Actualiser"
@@ -1614,7 +1599,7 @@ msgstr "Détails du service"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Services" msgid "Services"
msgstr "" msgstr "Services"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors." msgid "Set percentage thresholds for meter colors."
@@ -1658,7 +1643,7 @@ msgstr "Heure de début"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "État" msgstr "État"
@@ -1686,7 +1671,7 @@ msgstr "Utilisation du swap"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Changer de thème" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "Système"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Vitesses des ventilateurs du système (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "Ceci supprimera définitivement tous les enregistrements sélectionnés
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Débit de {extraFsName}" msgstr "Débit de {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Débit du pool de stockage {displayName}" msgstr "Débit du pool ZFS {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1802,7 +1787,7 @@ msgstr "Aux email(s)"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1821,7 +1806,7 @@ msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connex
#: src/components/ui/chart.tsx #: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx #: src/components/ui/chart.tsx
msgid "Total" msgid "Total"
msgstr "" msgstr "Total"
#: src/components/routes/system/network-sheet.tsx #: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface" msgid "Total data received for each interface"
@@ -1912,9 +1897,8 @@ msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "" msgstr "Type"
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Unhealthy" msgid "Unhealthy"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Token universel" msgstr "Token universel"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Inconnue" msgstr "Inconnue"
@@ -1961,7 +1945,7 @@ msgstr "Mettre à jour"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Mis à jour" msgstr "Mis à jour"
@@ -1984,20 +1968,20 @@ msgstr "Temps de fonctionnement"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Utilisation" msgstr "Utilisation"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Utilisation du pool de stockage {displayName}" msgstr "Utilisation du pool ZFS {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Utilisé" msgstr "Utilisé"
@@ -2074,7 +2058,7 @@ msgstr "Commande Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Commande Windows"
msgid "Write" msgid "Write"
msgstr "Écriture" msgstr "Écriture"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Erreurs d’écriture" msgstr "Erreurs d’écriture"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: he\n" "Language: he\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Hebrew\n" "Language-Team: Hebrew\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n"
@@ -97,7 +97,7 @@ msgstr "5 דק'"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "פעולות" msgstr "פעולות"
@@ -196,7 +196,7 @@ msgstr "האם אתה בטוח?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "העתקה אוטומטית דורשת הקשר מאובטח." msgstr "העתקה אוטומטית דורשת הקשר מאובטח."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "זמין" msgstr "זמין"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "יכולות" msgstr "יכולות"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "קיבולת" msgstr "קיבולת"
@@ -391,14 +391,14 @@ msgstr "בדוק את שירות הניטור שלך"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "בדוק את שירות ההתראות שלך" msgstr "בדוק את שירות ההתראות שלך"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "שגיאות סכום ביקורת" msgstr "שגיאות סכום ביקורת"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "נקה" msgstr "נקה"
@@ -411,7 +411,7 @@ msgstr "לחץ על קונטיינר כדי לצפות במידע נוסף."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "לחץ על התקן כדי לצפות במידע נוסף." msgstr "לחץ על התקן כדי לצפות במידע נוסף."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "לחצו על מאגר כדי להציג פרטי vdev ומערכי נתונים." msgstr "לחצו על מאגר כדי להציג פרטי vdev ומערכי נתונים."
@@ -503,7 +503,7 @@ msgstr "העתק שם"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "העתק מפתח ציבורי" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -530,7 +530,7 @@ msgstr "ליבה"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -869,14 +869,14 @@ msgstr "נכשל: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "מאווררים" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "למשך <0>{min}</0> {min, plural, one {דקה} other {דקות}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "שכחת סיסמה?" msgstr "שכחת סיסמה?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "פנוי" msgstr "פנוי"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "רשת" msgstr "רשת"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "בריאות" msgstr "בריאות"
@@ -1146,7 +1146,7 @@ msgstr "שימוש בזיכרון של קונטיינרים"
msgid "Model" msgid "Model"
msgstr "דגם" msgstr "דגם"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "נקודת עגינה" msgstr "נקודת עגינה"
@@ -1185,7 +1185,7 @@ msgstr "יחידת רשת"
msgid "No" msgid "No"
msgstr "לא" msgstr "לא"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "אין נתונים מפורטים עבור מאגר זה." msgstr "אין נתונים מפורטים עבור מאגר זה."
@@ -1212,7 +1212,7 @@ msgstr "אין מאפייני S.M.A.R.T. זמינים עבור התקן זה."
msgid "No systems found." msgid "No systems found."
msgstr "לא נמצאו מערכות." msgstr "לא נמצאו מערכות."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "ללא" msgstr "ללא"
@@ -1255,7 +1255,7 @@ msgstr "סיסמה חד-פעמית"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "פתח תפריט" msgstr "פתח תפריט"
@@ -1346,10 +1346,6 @@ msgstr "קבוע"
msgid "Persistence" msgid "Persistence"
msgstr "עקביות" msgstr "עקביות"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "שטח פיזי של ההתקן. הקיבולת האמיתית הניתנת לשימוש אינה ידועה. התראות השימוש בדיסקים של המאגר מושבתות."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "אנא <0>הגדר שרת SMTP</0> כדי להבטיח שהתראות יישלחו." msgstr "אנא <0>הגדר שרת SMTP</0> כדי להבטיח שהתראות יישלחו."
@@ -1383,11 +1379,11 @@ msgstr "אנא ראה <0>את התיעוד</0> להוראות."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "אנא התחבר לחשבון שלך" msgstr "אנא התחבר לחשבון שלך"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "מצב המאגר" msgstr "מצב המאגר"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "שימוש במאגר" msgstr "שימוש במאגר"
@@ -1420,7 +1416,7 @@ msgstr "תהליך התחיל"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "מפתח ציבורי" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "עומק תור"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "שעות שקט" msgstr "שעות שקט"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "גולמי"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "שימוש גולמי במאגר האחסון {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "שימוש גולמי במאגר האחסון {displayName}"
msgid "Read" msgid "Read"
msgstr "קריאה" msgstr "קריאה"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "שגיאות קריאה" msgstr "שגיאות קריאה"
@@ -1469,7 +1454,7 @@ msgstr "התקבל"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "רענן" msgstr "רענן"
@@ -1658,7 +1643,7 @@ msgstr "זמן התחלה"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "מצב" msgstr "מצב"
@@ -1686,7 +1671,7 @@ msgstr "שימוש ב-Swap"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "החלף ערכת נושא" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "מערכת"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "מהירויות מאווררי המערכת (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1787,9 +1772,9 @@ msgstr "פעולה זו תמחק לצמיתות את כל הרשומות שנב
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "תפוקה של {extraFsName}" msgstr "תפוקה של {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "תפוקה של מאגר האחסון {displayName}" msgstr "תפוקת מאגר ZFS ‏{poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1802,7 +1787,7 @@ msgstr "לאימייל(ים)"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1912,7 +1897,6 @@ msgstr "מופעל כאשר שימוש בכל דיסק עולה על סף"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "סוג" msgstr "סוג"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "token אוניברסלי" msgstr "token אוניברסלי"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "לא ידוע" msgstr "לא ידוע"
@@ -1961,7 +1945,7 @@ msgstr "עדכן"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "עודכן" msgstr "עודכן"
@@ -1984,20 +1968,20 @@ msgstr "זמן פעילות"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "שימוש" msgstr "שימוש"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "שימוש במאגר האחסון {displayName}" msgstr "שימוש במאגר ZFS ‏{poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "בשימוש" msgstr "בשימוש"
@@ -2074,7 +2058,7 @@ msgstr "פקודת Windows"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "פקודת Windows"
msgid "Write" msgid "Write"
msgstr "כתיבה" msgstr "כתיבה"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "שגיאות כתיבה" msgstr "שגיאות כתיבה"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: hr\n" "Language: hr\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-10 00:33\n" "PO-Revision-Date: 2026-09-02 21:43\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Croatian\n" "Language-Team: Croatian\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
@@ -97,7 +97,7 @@ msgstr "5 minuta"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Actions" msgid "Actions"
msgstr "Akcije" msgstr "Akcije"
@@ -142,7 +142,7 @@ msgstr "Prilagodite širinu glavnog rasporeda"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/navbar.tsx #: src/components/navbar.tsx
msgid "Admin" msgid "Admin"
msgstr "" msgstr "Admin"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "After" msgid "After"
@@ -154,7 +154,7 @@ msgstr "Nakon postavljanja varijabli okruženja, ponovno pokrenite svoj Beszel h
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -196,7 +196,7 @@ msgstr "Jeste li sigurni?"
msgid "Automatic copy requires a secure context." msgid "Automatic copy requires a secure context."
msgstr "Automatsko kopiranje zahtijeva siguran kontekst." msgstr "Automatsko kopiranje zahtijeva siguran kontekst."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Disk space available" msgctxt "Disk space available"
msgid "Available" msgid "Available"
msgstr "Dostupno" msgstr "Dostupno"
@@ -338,7 +338,7 @@ msgid "Capabilities"
msgstr "Mogućnosti" msgstr "Mogućnosti"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Capacity" msgid "Capacity"
msgstr "Kapacitet" msgstr "Kapacitet"
@@ -348,7 +348,7 @@ msgstr "Oprez - mogući gubitak podataka"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Celsius (°C)" msgid "Celsius (°C)"
msgstr "" msgstr "Celsius (°C)"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Change display units for metrics." msgid "Change display units for metrics."
@@ -391,14 +391,14 @@ msgstr "Provjerite svoju uslugu nadzora"
msgid "Check your notification service" msgid "Check your notification service"
msgstr "Provjerite svoju obavještajnu uslugu" msgstr "Provjerite svoju obavještajnu uslugu"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Checksum errors" msgid "Checksum errors"
msgstr "Pogreške kontrolnog zbroja" msgstr "Pogreške kontrolnog zbroja"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Clear" msgid "Clear"
msgstr "Očisti" msgstr "Očisti"
@@ -411,7 +411,7 @@ msgstr "Kliknite na spremnik za prikaz više informacija."
msgid "Click on a device to view more information." msgid "Click on a device to view more information."
msgstr "Kliknite na uređaj da biste vidjeli više informacija." msgstr "Kliknite na uređaj da biste vidjeli više informacija."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Click on a pool to view vdev and dataset details." msgid "Click on a pool to view vdev and dataset details."
msgstr "Kliknite na spremište za prikaz pojedinosti o vdevovima i skupovima podataka." msgstr "Kliknite na spremište za prikaz pojedinosti o vdevovima i skupovima podataka."
@@ -503,7 +503,7 @@ msgstr "Kopiraj naziv"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Copy public key" msgid "Copy public key"
msgstr "Kopiraj javni ključ" msgstr ""
#: src/components/copy-to-clipboard.tsx #: src/components/copy-to-clipboard.tsx
msgid "Copy text" msgid "Copy text"
@@ -661,7 +661,7 @@ msgstr "Prazni se"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Disk" msgid "Disk"
msgstr "" msgstr "Disk"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Disk unit" msgid "Disk unit"
@@ -732,7 +732,7 @@ msgstr "Uredi {foo}"
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx #: src/components/login/otp-forms.tsx
msgid "Email" msgid "Email"
msgstr "" msgstr "Email"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Email notifications" msgid "Email notifications"
@@ -869,14 +869,14 @@ msgstr "Neuspjelo: {0}"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "Fans" msgid "Fans"
msgstr "Ventilatori" msgstr ""
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/chart-card.tsx #: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
msgid "Filter..." msgid "Filter..."
@@ -898,8 +898,8 @@ msgstr "Za <0>{min}</0> {min, plural, one {minutu} other {minute}}"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Zaboravljena lozinka?" msgstr "Zaboravljena lozinka?"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgctxt "Free space" msgctxt "Free space"
msgid "Free" msgid "Free"
msgstr "Slobodno" msgstr "Slobodno"
@@ -948,7 +948,7 @@ msgid "Grid"
msgstr "Rešetka" msgstr "Rešetka"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Health" msgid "Health"
msgstr "Zdravlje" msgstr "Zdravlje"
@@ -972,7 +972,7 @@ msgstr "Homebrew naredba"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Host / IP" msgid "Host / IP"
msgstr "" msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method" msgid "HTTP Method"
@@ -1146,7 +1146,7 @@ msgstr "Upotreba memorije spremnika"
msgid "Model" msgid "Model"
msgstr "" msgstr ""
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Mountpoint" msgid "Mountpoint"
msgstr "Točka montiranja" msgstr "Točka montiranja"
@@ -1185,7 +1185,7 @@ msgstr "Mjerna jedinica za mrežu"
msgid "No" msgid "No"
msgstr "Ne" msgstr "Ne"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "No detail data for this pool." msgid "No detail data for this pool."
msgstr "Nema detaljnih podataka za ovo spremište." msgstr "Nema detaljnih podataka za ovo spremište."
@@ -1212,7 +1212,7 @@ msgstr "Nema dostupnih S.M.A.R.T. atributa za ovaj uređaj."
msgid "No systems found." msgid "No systems found."
msgstr "Nije pronađen nijedan sustav." msgstr "Nije pronađen nijedan sustav."
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "None" msgid "None"
msgstr "Nema" msgstr "Nema"
@@ -1255,7 +1255,7 @@ msgstr "Jednokratna lozinka"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Open menu" msgid "Open menu"
msgstr "Otvori meni" msgstr "Otvori meni"
@@ -1346,10 +1346,6 @@ msgstr "Trajan"
msgid "Persistence" msgid "Persistence"
msgstr "Postojanost" msgstr "Postojanost"
#: src/components/routes/system/raw-capacity-label.tsx
msgid "Physical device space. True usable capacity is unknown. Pool disk usage alerts are disabled."
msgstr "Fizički prostor uređaja. Stvarni iskoristivi kapacitet nije poznat. Upozorenja o iskorištenosti diskova spremišta su onemogućena."
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered." msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Molimo <0>konfigurirajte SMTP server</0> kako biste osigurali isporuku upozorenja." msgstr "Molimo <0>konfigurirajte SMTP server</0> kako biste osigurali isporuku upozorenja."
@@ -1383,17 +1379,17 @@ msgstr "Molimo provjerite <0>dokumentaciju</0> za upute."
msgid "Please sign in to your account" msgid "Please sign in to your account"
msgstr "Molimo prijavite se u svoj račun" msgstr "Molimo prijavite se u svoj račun"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Pool Health" msgid "Pool Health"
msgstr "Stanje spremišta" msgstr "Stanje spremišta"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Pool Usage" msgid "Pool Usage"
msgstr "Iskorištenost spremišta" msgstr "Iskorištenost spremišta"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
@@ -1420,7 +1416,7 @@ msgstr "Proces pokrenut"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Public key" msgid "Public key"
msgstr "Javni ključ" msgstr ""
#. Use 'Key' if your language requires many more characters #. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx #: src/components/add-system.tsx
@@ -1436,21 +1432,10 @@ msgstr "Dubina reda"
msgid "Quiet Hours" msgid "Quiet Hours"
msgstr "Tihi sati" msgstr "Tihi sati"
#: src/components/routes/system/raw-capacity-label.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Raw"
msgstr "Sirovo"
#: src/components/routes/system/charts/storage-pool-charts.tsx
msgid "Raw usage of storage pool {displayName}"
msgstr "Sirova iskorištenost spremišta {displayName}"
#. Disk read #. Disk read
#. Disk read #. Disk read
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -1458,7 +1443,7 @@ msgstr "Sirova iskorištenost spremišta {displayName}"
msgid "Read" msgid "Read"
msgstr "Pročitaj" msgstr "Pročitaj"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Read errors" msgid "Read errors"
msgstr "Pogreške čitanja" msgstr "Pogreške čitanja"
@@ -1469,7 +1454,7 @@ msgstr "Primljeno"
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx #: src/components/containers-table/containers-table.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Refresh" msgid "Refresh"
msgstr "Osvježi" msgstr "Osvježi"
@@ -1658,7 +1643,7 @@ msgstr "Vrijeme početka"
#. Context: alert state (active or resolved) #. Context: alert state (active or resolved)
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "State" msgid "State"
msgstr "Stanje" msgstr "Stanje"
@@ -1669,7 +1654,7 @@ msgstr "Stanje"
#: src/components/systems-table/systems-table.tsx #: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
msgid "Status" msgid "Status"
msgstr "" msgstr "Status"
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State" msgid "Sub State"
@@ -1686,7 +1671,7 @@ msgstr "Swap Iskorištenost"
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx #: src/components/mode-toggle.tsx
msgid "Switch theme" msgid "Switch theme"
msgstr "Promijeni temu" msgstr ""
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx #: src/components/alerts-history-columns.tsx
@@ -1705,7 +1690,7 @@ msgstr "Sustav"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
msgid "System fan speeds (RPM)" msgid "System fan speeds (RPM)"
msgstr "Brzine ventilatora sustava (RPM)" msgstr ""
#: src/components/routes/system/charts/load-average-chart.tsx #: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time" msgid "System load averages over time"
@@ -1740,7 +1725,7 @@ msgstr "Zadaci"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Temp" msgid "Temp"
msgstr "" msgstr "Temp"
#: src/components/routes/system/charts/sensor-charts.tsx #: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts #: src/lib/alerts.ts
@@ -1787,9 +1772,9 @@ msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podatak
msgid "Throughput of {extraFsName}" msgid "Throughput of {extraFsName}"
msgstr "Protok {extraFsName}" msgstr "Protok {extraFsName}"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Throughput of storage pool {displayName}" msgid "Throughput of ZFS pool {poolName}"
msgstr "Protok spremišta {displayName}" msgstr "Propusnost ZFS spremišta {poolName}"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Time format" msgid "Time format"
@@ -1802,7 +1787,7 @@ msgstr "Primaoci emaila"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1834,7 +1819,7 @@ msgstr "Ukupni podaci poslani za svako sučelje"
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O" msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)" msgid "Total time spent on read/write (can exceed 100%)"
msgstr "Ukupno vrijeme utrošeno na čitanje/pisanje (može prelaziti 100 %)" msgstr ""
#. placeholder {0}: data.length #. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
@@ -1912,7 +1897,6 @@ msgstr "Pokreće se kada iskorištenost bilo kojeg diska premaši prag"
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx #: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
msgid "Type" msgid "Type"
msgstr "Vrsta" msgstr "Vrsta"
@@ -1935,7 +1919,7 @@ msgid "Universal token"
msgstr "Sveopći token" msgstr "Sveopći token"
#. Context: Battery state #. Context: Battery state
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/lib/i18n.ts #: src/lib/i18n.ts
msgid "Unknown" msgid "Unknown"
msgstr "Nepoznato" msgstr "Nepoznato"
@@ -1961,7 +1945,7 @@ msgstr "Ažuriraj"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
msgid "Updated" msgid "Updated"
msgstr "Ažurirano" msgstr "Ažurirano"
@@ -1984,20 +1968,20 @@ msgstr "Vrijeme rada"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "Usage" msgid "Usage"
msgstr "Iskorištenost" msgstr "Iskorištenost"
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-charts.tsx
msgid "Usage of storage pool {displayName}" msgid "Usage of ZFS pool {poolName}"
msgstr "Iskorištenost spremišta {displayName}" msgstr "Iskorištenost ZFS spremišta {poolName}"
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx #: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Used" msgid "Used"
msgstr "Iskorišteno" msgstr "Iskorišteno"
@@ -2074,7 +2058,7 @@ msgstr "Windows naredba"
#. Disk write #. Disk write
#. Disk write #. Disk write
#: src/components/routes/system/charts/disk-charts.tsx #: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/storage-pool-charts.tsx #: src/components/routes/system/charts/zfs-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 #: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx #: src/components/routes/system/disk-io-sheet.tsx
@@ -2082,7 +2066,7 @@ msgstr "Windows naredba"
msgid "Write" msgid "Write"
msgstr "Piši" msgstr "Piši"
#: src/components/routes/system/storage-pools-table.tsx #: src/components/routes/system/zfs-table.tsx
msgid "Write errors" msgid "Write errors"
msgstr "Pogreške zapisivanja" msgstr "Pogreške zapisivanja"

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