Compare commits

..

51 Commits

Author SHA1 Message Date
henrygd
c7f2177b08 wifi: hide chart without signal data, show unknown signal in table
The line chart never renders without values, leaving an endless spinner
when connected interfaces report no RSSI.
2026-09-25 20:09:34 -04:00
henrygd
a042e19549 wifi(linux): query associated station directly
mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
out-of-tree Realtek USB) answer with an empty list, leaving RSSI
unavailable. Request the associated BSSID's station directly, as
`iw link` does, and parse it with native.ParseStationInfo.
2026-09-25 19:59:18 -04:00
henrygd
fe83f5b831 add signal strength indicator dot to all systems table 2026-09-25 19:24:11 -04:00
henrygd
46fa7c581e use tooltip for all systems table cell 2026-09-25 18:53:25 -04:00
henrygd
24792aa24f wifi: omit empty info snapshot and tidy up
- Info.WiFi uses json "wf" with omitempty so systems without Wi-Fi no
  longer store/broadcast "wifi":null
- move osascript exec into wifi_darwin.go; drop unused commandRunner
- share strongest-connection logic between table cell and sorting
- trim agent/wifi README
2026-09-25 18:43:05 -04:00
henrygd
16e3fbadce store compact RSSI stats and skip real-time collection
Stats.WiFi is now map[string]int8 (json "wf") holding only available
RSSI
readings; SSID and unavailable signals stay in Info.WiFi. Averages are
rounded to whole dBm.

Wi-Fi is collected only on the default 60s interval; real-time requests
reuse the last snapshot to avoid spawning osascript / dumping the BSS
cache every second.
2026-09-25 18:02:34 -04:00
Vito Cappello
86ab0fae8b feat: monitor connected Wi-Fi signal per interface (#2367) 2026-09-25 17:43:57 -04:00
henrygd
badd4c8245 fix(hub): improve GPU chart layout
- Let GPU Power Draw span full width when it's a lone card in the grid
- Split GPU charts into summary (power/engines) and per-GPU grids so
  usage and VRAM charts stay paired side by side
2026-09-25 13:55:59 -04:00
henrygd
7bea20e3b6 refactor(agent): remove unused FsStats.Time
The disk I/O seed now reads from diskBaseline, so nothing reads
FsStats.Time anymore.
2026-09-25 13:50:19 -04:00
Ludwig J. Marx
433b83800f fix(agent): measure first disk I/O sample of an interval from the full baseline (#2420) 2026-09-25 13:38:04 -04:00
henrygd
3dfe062ee4 fix(agent): promote existing entry when root key is already registered
When the root drive is also listed in EXTRA_FILESYSTEMS, its key is
taken before addPartitionRootFs runs. Returning false there sent the
agent to addLastResortRootFs, which picks the most active device and
could register a different drive as root, overwriting that drive's
extra entry. addPartitionRootFs has already resolved the root device,
so promote the existing entry to root instead.
2026-09-25 13:24:11 -04:00
henrygd
3fb97b800c fix(agent): fall back to last-resort root when root key is taken
addPartitionRootFs reported success even when addFsStat skipped the
registration because the I/O key was already in use, e.g. when the root
drive is also listed in EXTRA_FILESYSTEMS. hasRoot was then set without
any root filesystem, and the last-resort fallback never ran. addFsStat
now reports whether it registered the filesystem and addPartitionRootFs
propagates that.

Also uppercase the drive letter in windowsVolumeName so "c:" and "C:"
normalize to the same key.
2026-09-25 13:15:14 -04:00
yi111
d708def38f fix(agent): keep Windows volume names intact when matching I/O devices (#2419)
filepath.Base is platform-dependent for a bare volume name: on Windows it
strips the "C:" specifier and returns "\", so every drive letter
normalizes to the same key. findIoDevice's exact match then returns
whichever disk.IOCounters entry the map yielded first, which registers the
root filesystem under a random drive. With EXTRA_FILESYSTEMS=D:,P: the
root disk disappears from the hub, and without it the root can be
registered under another drive's I/O device.

Normalize a bare volume specifier before taking a path base, and stop
taking a path base of the device in addPartitionRootFs, where it mangled
the value before findIoDevice could see it. registerFilesystemStats
already avoided this by only taking a base when the agent is not on
Windows.

Fixes #2417
2026-09-25 13:09:11 -04:00
henrygd
f50fb4f8e5 fix(agent): fix pacman update check under systemd sandbox
- store checkupdates' private DB in the agent data dir, since
  ProtectSystem=strict makes /tmp read-only
- sync the private DB every 12h and use `checkupdates -n` in between,
  forcing a sync when the DB is missing so -n never reports a false 0
- set WaitDelay so a timed-out command can't hang on child processes
2026-09-25 12:21:15 -04:00
Anand Hegde
c25408651f feat: show number of pending package updates (#2357) 2026-09-25 12:13:37 -04:00
henrygd
d591da46f3 remove agent data directory on uninstall 2026-09-25 11:35:58 -04:00
henrygd
d80a2f49f9 fix(ui): hide stray 0 in info bar for paused systems 2026-09-24 19:56:23 -04:00
spatiumstas
21b648a005 fix(site): prevent scrollbar layout shifts (#2406) 2026-09-24 19:25:56 -04:00
henrygd
151423ac63 fix(ui): size monitors system column by systems in the table
Base the network monitors system column width on the longest name among
systems that have monitors in the table, rather than all systems.
2026-09-24 19:13:48 -04:00
Sven van Ginkel
f7528a0208 feat: Add option to define which DNS server a DNS monitor queries (#2389) 2026-09-24 19:01:10 -04:00
henrygd
a20a7d2edc fix(ui): correct navbar users link 2026-09-24 18:21:17 -04:00
henrygd
f2adb9cf94 fix(ui): correct navbar users link 2026-09-24 18:18:00 -04:00
Sven van Ginkel
4d10ea2e03 feat: add SYNC_SYSTEM_NAMES env var to sync system names with hostname (#1917) 2026-09-24 17:45:59 -04:00
Ludwig J. Marx
b5ef015451 fix(agent): handle 32-bit wrap of disk I/O time counters (#2407) 2026-09-24 17:39:53 -04:00
Miłosz Kolber
6141b15f03 fix(agent): SKIP_GPU excludes GPU hwmon from temperatures and fans (#2313) 2026-09-24 13:59:52 -04:00
henrygd
c21412f45d i18n: update strings 2026-09-24 12:29:12 -04:00
spatiumstas
367d2f39da fix(i18n): translate system actions as complete phrases (#2403) 2026-09-24 12:23:42 -04:00
hank
eabd9a950a New Crowdin updates (#2384) 2026-09-24 12:21:49 -04:00
Sven van Ginkel
0be9882b34 feat(ui): simplify Services column in systems table (#2399)
Co-authored-by: hank <hank@henrygd.me>
2026-09-24 12:05:26 -04:00
Mario Mohar
e4b84b72ab fix(site): measure the longest string width instead of estimating it (#2411)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-24 11:32:21 -04:00
Mario Mohar
fc33e62736 fix(hub): restart beszel-hub.service after update (#2410)
restartService only looked for beszel.service, but install-hub.sh installs
the unit as beszel-hub.service, so the restart was skipped on a standard
install and the hub kept running the old binary.

Try the installer's name first and keep beszel.service as a fallback for
hand written units. Same for the OpenRC branch.
2026-09-24 10:00:18 -04:00
Sven van Ginkel
a99fe5e997 feat: add TLS certificate expiry check to HTTPS network monitors (#2401)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-23 17:07:29 -04:00
spatiumstas
0870716052 fix(i18n): extract bulk monitor format hint for translation (#2362) 2026-09-23 15:19:30 -04:00
Sven van Ginkel
b1270e341c feat(ui): Add view settings to network monitors table (#2396) 2026-09-23 13:47:42 -04:00
Adrien Boullé
9042a8c5c8 fix(site): handle missing default chart time (#2397)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-23 12:29:31 -04:00
henrygd
8bf6917fe0 fix: prevent WebSocket reconnect loops on slow agent collections (#2294)
The hub waited only 5s (the request manager default) for stats over
WebSocket and closed the connection on any error. On hosts where
`zpool list` stalls (seen on Proxmox, up to its 10s timeout), collection
exceeded that limit, so the hub sent a close (code 1000) and the agent
reconnected. The refresh ran every other cycle, which caused a
disconnect roughly every 2 minutes.

Hub:
- Wait up to 30s for WebSocket stats responses.
- Keep the connection open when a stats request times out; only close
  it (and fall back to SSH) for other errors.

Agent:
- After the first collection, refresh `zpool list` pool stats and
  `zfs list` dataset usage in the background and serve cached values
  meanwhile, so a hung utility cannot delay the stats response.
2026-09-23 11:21:51 -04:00
spatiumstas
2d5ea3fa08 fix: preserve status alerts through pending recovery (#2375) 2026-09-22 19:14:54 -04:00
Sven van Ginkel
627d364071 fix(ui): disable Add Monitor button when no eligible systems exist (#2392) 2026-09-22 19:04:48 -04:00
Sven van Ginkel
8047f005d4 fix(ui): preserve paused systems in network monitors table (#2391) 2026-09-22 18:40:46 -04:00
Sven van Ginkel
4a4610bbc3 fix(ui): reflect target response status in network monitor status dot (#2390)
Co-authored-by: henrygd <hank@henrygd.me>
2026-09-22 18:12:19 -04:00
henrygd
1aaabfc255 fix(agent): check all image repository digests (#2393) 2026-09-22 18:00:13 -04:00
henrygd
97ea3c16cb add custom user agent to HTTP monitors 2026-09-21 19:53:53 -04:00
Sven van Ginkel
c9de35fad2 feat: change network packet loss to a red line (#2377) 2026-09-21 14:49:11 -04:00
henrygd
a5f216f425 exit 0 without key on Windows to avoid Winget Validation-Executable-Error (#2376, #2247) 2026-09-21 11:27:37 -04:00
henrygd
cbe4824ac3 add env var to disable container image update checks (#2371) 2026-09-21 10:49:53 -04:00
henrygd
2c69197d2d fix(install): improve handling of openwrt user account (#2370)
- Allocate unused UID/GIDs instead of using 999
- Detect existing ID collisions and repair missing shadow entries
- Support userdel and deluser during uninstall
- Fix Docker group membership handling
2026-09-20 18:04:07 -04:00
henrygd
97e6f64bdc fix(ui): swap usage chart shown twice in tabs view (#2356) 2026-09-19 13:27:00 -04:00
henrygd
4a5915b141 add SMART to readme features 2026-09-19 12:11:10 -04:00
henrygd
e68372dce4 update readme features 2026-09-19 12:09:26 -04:00
henrygd
c52f3acb94 fix(docker): enable Debian contrib for ZFS utilities in slim NVIDIA image 2026-09-19 12:03:05 -04:00
hank
c09eb8c6df chore(helm): update app version to 0.20.0 (#2355)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-19 11:39:13 -04:00
132 changed files with 6701 additions and 770 deletions

View File

@@ -29,6 +29,7 @@ type Agent struct {
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
diskBaseline map[string]prevDisk // Latest disk I/O counters of any interval, seeds a new interval
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
lastDiskUsageUpdate time.Time // Last time disk usage was collected
netInterfaces map[string]struct{} // Stores all valid network interfaces
@@ -50,6 +51,7 @@ type Agent struct {
systemdManager *systemdManager // Manages systemd services
monitorManager *MonitorManager // Manages network monitors
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
packageUpdates *packageUpdatesManager // Checks for pending package updates
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -155,6 +157,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug("SMART", "err", err)
}
agent.packageUpdates = newPackageUpdatesManager(agent.dataDir)
// initialize GPU manager
agent.gpuManager, err = NewGPUManager()
if err != nil {
@@ -219,6 +223,10 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.packageUpdates != nil {
data.Info.PackageUpdates = a.packageUpdates.get(time.Now())
}
data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats {
@@ -252,7 +260,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
// Start initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys
return a.connectionManager.Start(serverOptions)
err := a.connectionManager.Start(serverOptions)
if err != nil {
a.cleanupSensorShadow()
}
return err
}
func (a *Agent) getFingerprint() string {

View File

@@ -155,6 +155,7 @@ func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.agent.monitorManager.Stop()
c.closeWebSocket()
c.agent.cleanupSensorShadow()
return health.CleanUp()
}

View File

@@ -3,6 +3,7 @@ package agent
import (
"context"
"log/slog"
"math"
"os"
"path/filepath"
"runtime"
@@ -153,12 +154,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
}
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
// key. The key selection itself lives in buildFsStatRegistration so that logic
// can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
// key and reports whether it was added. The key selection itself lives in
// registerFilesystemStats so that logic can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
if !ok {
return
return false
}
d.agent.fsStats[key] = fsStats
name := key
@@ -166,6 +167,7 @@ func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customNa
name = customName
}
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
return true
}
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
@@ -203,15 +205,25 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
// partition looks like the active root mount but still needs translating to an
// I/O device key.
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
// device is passed through as-is: findIoDevice normalizes it, and
// filepath.Base would turn a Windows volume name such as "C:" into "\"
// on the way in (#2417).
fs, match := findIoDevice(device, d.ctx.diskIoCounters)
if !match {
return false
}
// The resolved I/O device is already known here, so use it directly to avoid
// a second fallback search inside buildFsStatRegistration.
d.addFsStat(fs, mountpoint, true, "")
// The root device is already resolved, so if it was registered earlier as an
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
// entry rather than letting addLastResortRootFs guess a different device.
if stats, exists := d.agent.fsStats[fs]; exists {
stats.Root = true
stats.Mountpoint = mountpoint
return true
}
// Use the resolved I/O device directly to avoid a second fallback search
// inside registerFilesystemStats.
return d.addFsStat(fs, mountpoint, true, "")
}
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
// heuristics can identify root, so it picks the busiest I/O device as a final
@@ -526,13 +538,43 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
// normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string {
name := filepath.Base(strings.TrimSpace(value))
name := strings.TrimSpace(value)
if volume, ok := windowsVolumeName(name); ok {
return volume
}
name = filepath.Base(name)
if name == "." {
return ""
}
return name
}
// windowsVolumeName returns the canonical form of a bare Windows volume
// specifier, so that "C:", "c:", `C:\` and "C:/" all name the same drive.
// Drive letters are case-insensitive on Windows, so the letter is uppercased.
//
// filepath.Base cannot do this. On Windows it treats "C:" as a volume name
// with no path element to take the base of and returns "\", so every drive
// letter normalizes to the same key. findIoDevice then returns whichever
// counter the map happened to yield first, which registers the root
// filesystem under a random drive (#2417).
func windowsVolumeName(value string) (string, bool) {
if len(value) < 2 || value[1] != ':' {
return "", false
}
if c := value[0]; !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
return "", false
}
// Only separators may follow the specifier. "C:data" is a drive-relative
// path, not a volume.
for i := 2; i < len(value); i++ {
if value[i] != '\\' && value[i] != '/' {
return "", false
}
}
return strings.ToUpper(value[:2]), true
}
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
a.fsNames = a.fsNames[:0]
@@ -554,9 +596,9 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
continue
}
// populate initial values
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
a.setDiskBaseline(device, prevDiskFromCounter(d, now))
// add to list of valid io device names
a.fsNames = append(a.fsNames, device)
}
@@ -639,19 +681,9 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{
readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() {
// Seed from the latest counters of any interval, else seed from current
prev, hasPrev = a.diskBaseline[name]
if !hasPrev {
prev = prevDiskFromCounter(d, now)
}
}
@@ -686,29 +718,31 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
diskReadTime := utils.TwoDecimals(float64(deltaReadTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(deltaWriteTime) / float64(msElapsed) * 100)
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
// r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
}
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
}
// Update global fsStats baseline for cross-interval correctness
stats.Time = now
// Update the baseline that seeds new intervals
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
stats.DiskReadPs = readMbPerSecond
@@ -740,6 +774,30 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
}
}
// setDiskBaseline stores the latest counters of a device. A cache interval
// without its own snapshot measures its first sample from them.
func (a *Agent) setDiskBaseline(name string, d prevDisk) {
if a.diskBaseline == nil {
a.diskBaseline = make(map[string]prevDisk)
}
a.diskBaseline[name] = d
}
// ioTimeDelta returns the increase of a cumulative millisecond counter from
// the disk I/O stats. Linux prints these fields of /proc/diskstats as 32-bit
// unsigned ints, so they wrap to zero at 2^32. A busy disk reaches that in
// days for the weighted I/O time. Other platforms report 64-bit counters,
// so a lower value there is a reset.
func ioTimeDelta(current, previous uint64) uint64 {
if current >= previous {
return current - previous
}
if runtime.GOOS == "linux" && previous <= math.MaxUint32 {
return current + (math.MaxUint32 + 1 - previous)
}
return 0
}
// getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /

125
agent/disk_io_linux_test.go Normal file
View File

@@ -0,0 +1,125 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Linux prints four millisecond fields of /proc/diskstats as 32-bit unsigned ints:
// read time, write time, io time and weighted io time. They wrap to zero at 2^32.
func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
const wrap = uint64(1) << 32
tests := []struct {
name string
base uint64 // added to every previous time counter
}{
{"no wrap", 0},
{"32-bit wrap", wrap - 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Deltas over 60s: read 300ms / 10 ops, write 400ms / 20 ops,
// io time 1200ms, weighted io 3000ms.
prev := prevDisk{
readBytes: 20000 * 512,
writeBytes: 10000 * 512,
readTime: tt.base + 900,
writeTime: tt.base + 700,
ioTime: tt.base + 400,
weightedIO: tt.base,
readCount: 1000,
writeCount: 500,
at: time.Now().Add(-60 * time.Second),
}
cur := func(v uint64) uint64 { return v % wrap }
line := fmt.Sprintf(" 8 0 sda %d 0 %d %d %d 0 %d %d 0 %d %d\n",
1010, 21200, cur(prev.readTime+300),
520, 10400, cur(prev.writeTime+400),
cur(prev.ioTime+1200), cur(prev.weightedIO+3000))
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
fs := &system.FsStats{Root: true}
a := &Agent{
fsNames: []string{"sda"},
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{60000: {"sda": prev}},
}
var stats system.Stats
a.updateDiskIo(60000, &stats)
// Same order as DiskIoStats in system.FsStats.
want := [6]float64{0.5, 0.67, 2, 30, 20, 5}
for i := range want {
assert.InDelta(t, want[i], fs.DiskIoStats[i], 0.01, "DiskIoStats[%d]", i)
assert.InDelta(t, want[i], stats.DiskIoStats[i], 0.01, "system DiskIoStats[%d]", i)
}
})
}
}
// The first sample of a cache interval has no snapshot of its own. It must
// measure the time counters from the same baseline as the byte counters.
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
writeDiskstats := func(line string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
}
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
counters, err := disk.IOCounters("sda")
require.NoError(t, err)
fs := &system.FsStats{Root: true}
a := &Agent{
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{},
}
a.initializeDiskIoStats(counters)
// updateDiskIo skips samples less than 100ms apart.
time.Sleep(150 * time.Millisecond)
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
var stats system.Stats
a.updateDiskIo(60000, &stats)
require.NotZero(t, fs.DiskReadBytes, "bytes are measured from the baseline")
for i := range 3 {
assert.NotZero(t, fs.DiskIoStats[i], "DiskIoStats[%d]", i)
}
assert.InDelta(t, 30, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 20, fs.DiskIoStats[4], 0.01, "w_await")
assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
// A second interval starts from the latest counters, not from the ones at start.
time.Sleep(150 * time.Millisecond)
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
a.updateDiskIo(1000, &stats)
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
}

View File

@@ -3,7 +3,9 @@
package agent
import (
"math"
"os"
"runtime"
"strings"
"testing"
"time"
@@ -1030,8 +1032,10 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Len(t, agent.fsNames, 2)
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
assert.False(t, agent.fsStats["sda"].Time.IsZero())
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes)
assert.False(t, agent.diskBaseline["sda"].at.IsZero())
assert.False(t, agent.diskBaseline["sdb"].at.IsZero())
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
@@ -1041,3 +1045,114 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
}
func TestIoTimeDelta(t *testing.T) {
assert.Equal(t, uint64(300), ioTimeDelta(1200, 900))
// A lower value is a 32-bit wrap only on Linux. Other platforms
// report 64-bit counters, so there it is a reset.
var want uint64
if runtime.GOOS == "linux" {
want = 1200
}
assert.Equal(t, want, ioTimeDelta(200, math.MaxUint32+1-1000))
assert.Equal(t, uint64(0), ioTimeDelta(200, math.MaxUint32+1000))
}
func TestNormalizeDeviceName(t *testing.T) {
// A Windows volume name is not a path element, so every spelling of the
// same drive has to normalize to the same key. filepath.Base cannot do
// this: on Windows it strips the "C:" specifier and returns "\", which
// collapses every drive letter onto one key (#2417).
for _, spelling := range []string{"C:", `C:\`, "C:/", `C:\\`} {
assert.Equal(t, "C:", normalizeDeviceName(spelling), "spelling %q", spelling)
}
// Drive letters are case-insensitive, so the letter is uppercased.
assert.Equal(t, "D:", normalizeDeviceName("d:"))
assert.Equal(t, "C:", normalizeDeviceName(" c: "))
assert.Equal(t, "C:", normalizeDeviceName(`c:\`))
// Non-volume inputs keep using filepath.Base.
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1"))
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1/"))
assert.Equal(t, "nvme0n1p2", normalizeDeviceName(" /dev/nvme0n1p2 "))
assert.Equal(t, "", normalizeDeviceName("."))
assert.Equal(t, "", normalizeDeviceName(" "))
// A drive-relative path is a path, not a volume.
assert.Equal(t, `C:data`, normalizeDeviceName(`C:data`))
}
func TestFindIoDeviceWindowsVolumeNames(t *testing.T) {
// Every drive normalizes to a distinct key, so the root drive resolves
// exactly instead of to whichever counter the map yielded first (#2417).
ioCounters := map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10, WriteBytes: 10},
"D:": {Name: "D:", ReadBytes: 20, WriteBytes: 20},
"P:": {Name: "P:", ReadBytes: 30, WriteBytes: 30},
}
for i := 0; i < 32; i++ {
device, ok := findIoDevice("C:", ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
// The drive may arrive with a trailing separator, as a mount point does.
device, ok := findIoDevice(`C:\`, ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
func TestAddPartitionRootFsWindowsDrive(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:"},
"D:": {Name: "D:"},
"P:": {Name: "P:"},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 1)
stats, exists := agent.fsStats["C:"]
assert.True(t, exists)
assert.True(t, stats.Root)
}
func TestAddPartitionRootFsKeyAlreadyRegistered(t *testing.T) {
// The root drive is also listed in EXTRA_FILESYSTEMS, so its key is taken
// before the root fallback runs. The existing entry must be promoted to root
// rather than falling back to the most active device, which here is D:.
agent := &Agent{fsStats: map[string]*system.FsStats{
"C:": {Mountpoint: `C:\`, Name: "System"},
"D:": {Mountpoint: `D:\`},
}}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: `C:\`,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10},
"D:": {Name: "D:", ReadBytes: 100},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 2)
assert.True(t, agent.fsStats["C:"].Root)
assert.Equal(t, `C:\`, agent.fsStats["C:"].Mountpoint)
assert.Equal(t, "System", agent.fsStats["C:"].Name)
assert.False(t, agent.fsStats["D:"].Root)
}

View File

@@ -69,6 +69,7 @@ type dockerManager struct {
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
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
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
@@ -688,6 +689,8 @@ func newDockerManager(agent *Agent) *dockerManager {
userAgent: "Docker-Client/",
}
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
@@ -711,6 +714,7 @@ func newDockerManager(agent *Agent) *dockerManager {
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers,
imageUpdatesDisabled: dockerImageCheck == "false",
// Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64),

View File

@@ -31,6 +31,9 @@ func normalizedImageReference(image string) 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) {
if dm.imageUpdatesDisabled {
return
}
dm.imageUpdatesMutex.Lock()
defer dm.imageUpdatesMutex.Unlock()
if dm.imageUpdatesRunning {

View File

@@ -27,6 +27,29 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
}, time.Second*3, time.Millisecond)
}
func TestDisableDockerImageUpdateCheck(t *testing.T) {
t.Setenv("BESZEL_AGENT_DOCKER_IMAGE_CHECK", "false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
fmt.Fprint(w, `{"Version":"25.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("BESZEL_AGENT_DOCKER_HOST", server.URL)
dm := newDockerManager(nil)
require.True(t, dm.imageUpdatesDisabled)
dm.registryClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("disabled image update check made a registry request")
return nil, nil
})}
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx", Names: []string{"/nginx"}}}, time.Now())
require.False(t, dm.imageUpdatesRunning)
require.Nil(t, dm.imageUpdates)
}
func TestImageUpdateCacheAndStats(t *testing.T) {
local := "sha256:" + strings.Repeat("a", 64)
remote := "sha256:" + strings.Repeat("b", 64)

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -37,7 +38,7 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
repository := reference.Path(named)
tag := named.(reference.Tagged).Tag()
localDigest, err := dm.inspectImageDigest(image, registry, repository)
localDigests, err := dm.inspectImageDigests(image, registry, repository)
if err != nil {
return false, err
}
@@ -47,48 +48,49 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
return false, err
}
return remoteDigest != localDigest, nil
return !slices.Contains(localDigests, remoteDigest), nil
}
// inspectImageDigest reads Docker's image metadata without using dm.decode.
// inspectImageDigests 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) {
func (dm *dockerManager) inspectImageDigests(image, registry, repository string) ([]string, error) {
if dm.client == nil {
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
return nil, 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)
return nil, 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))
return nil, 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)
return nil, 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)
return nil, 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)
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
if len(localDigests) == 0 {
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
}
return localDigest, nil
return localDigests, 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) {
// matchingRepositoryDigests returns all valid digests belonging to the requested
// repository. Container engines can return both index and platform manifest digests for one
// local image, in either order.
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
var digests []string
for _, repoDigest := range repoDigests {
repoDigest = strings.TrimSpace(repoDigest)
at := strings.LastIndexByte(repoDigest, '@')
@@ -108,9 +110,9 @@ func matchingRepositoryDigest(repoDigests []string, registry, repository string)
if err != nil {
continue
}
return d.String(), true
digests = append(digests, d.String())
}
return "", false
return digests
}
func sameRegistry(left, right string) bool {

View File

@@ -80,6 +80,45 @@ func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
require.EqualValues(t, 1, manifestCalls.Load())
}
func TestCheckImageUpdateMatchesAnyRepositoryDigest(t *testing.T) {
platform := registryDigest('a')
index := registryDigest('b')
other := registryDigest('c')
for _, test := range []struct {
name string
digests []string
remote string
available bool
}{
{name: "platform then index, remote index", digests: []string{platform, index}, remote: index},
{name: "index then platform, remote index", digests: []string{index, platform}, remote: index},
{name: "platform then index, remote platform", digests: []string{platform, index}, remote: platform},
{name: "index then platform, remote platform", digests: []string{index, platform}, remote: platform},
{name: "neither matches", digests: []string{platform, index}, remote: other, available: true},
} {
t.Run(test.name, func(t *testing.T) {
inspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/busybox@%s","docker.io/library/alpine@%s","docker.io/library/alpine@sha256:invalid","docker.io/library/alpine@%s"]}`, test.remote, test.digests[0], test.digests[1])
var manifestCalls atomic.Int32
dm := newRegistryChecker(t, inspect, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet {
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
}
manifestCalls.Add(1)
require.Equal(t, http.MethodHead, req.Method)
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", test.remote)
return resp, nil
}))
available, err := dm.checkImageUpdate("alpine")
require.NoError(t, err)
require.Equal(t, test.available, available)
require.EqualValues(t, 1, manifestCalls.Load())
})
}
}
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
for _, test := range []struct {
name string

View File

@@ -12,7 +12,7 @@ import (
)
type fanSensor struct {
key, path string
key, path, chip string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
@@ -34,6 +34,10 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
slog.Debug("Error reading fans", "err", err)
return
}
// Filter before reading fan*_input: each read can wake an idle GPU.
if a.sensorConfig != nil && a.sensorConfig.skipGPU {
sensors = filterGpuFans(sensors)
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
@@ -100,7 +104,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath})
sensors = append(sensors, fanSensor{key, inputPath, chipName})
}
}
return sensors, nil
@@ -115,3 +119,15 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
}
return fans
}
// filterGpuFans drops GPU chips without touching the shared cache backing array.
func filterGpuFans(sensors []fanSensor) []fanSensor {
kept := make([]fanSensor, 0, len(sensors))
for _, sensor := range sensors {
if isGpuChipName(sensor.chip) {
continue
}
kept = append(kept, sensor)
}
return kept
}

View File

@@ -103,3 +103,20 @@ func TestFanDiscoveryCache(t *testing.T) {
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}
func TestFilterGpuFans(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon1", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon1", "fan1_input"), "800\n")
discovered, err := discoverHwmonFans(root)
require.NoError(t, err)
require.Len(t, discovered, 2)
filtered := filterGpuFans(discovered)
require.Len(t, filtered, 1)
assert.Equal(t, "nct6798_fan1", filtered[0].key)
assert.Len(t, discovered, 2)
}

View File

@@ -750,9 +750,36 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
return priorities
}
// gpuHwmonChips are hwmon chip names belonging to GPUs. Sensor reads on some
// of these drivers (notably Intel Xe, where each read is a runtime PM resume)
// wake the card, so SKIP_GPU must avoid touching them, not just hide them.
var gpuHwmonChips = []string{"xe", "i915", "amdgpu", "radeon", "nvidia", "nouveau"}
func isGpuChipName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
for _, chip := range gpuHwmonChips {
if name == chip {
return true
}
}
return false
}
// SensorKeys are "<chip>" or "<chip>_<label>".
func isGpuSensorKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, chip := range gpuHwmonChips {
if key == chip || strings.HasPrefix(key, chip+"_") {
return true
}
}
return false
}
// NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
return nil, nil
}
var gm GPUManager

View File

@@ -15,6 +15,7 @@ type MonitorManager struct {
mu sync.RWMutex
monitors map[string]*monitorTask // keyed by monitor ID
probe monitorProbe
certCheck certChecker
resumeGuard monitorResumeGuard
}
@@ -23,7 +24,7 @@ func newMonitorManager() *MonitorManager {
}
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
}
// SyncMonitors replaces all monitor tasks with the given configs.
@@ -107,7 +108,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
if !runNow {
return nil, nil
}
return task.runProbe(pm.probe), nil
return pm.runNow(task), nil
}
if exists {
task.cancel()
@@ -119,7 +120,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
pm.mu.Unlock()
if runNow {
result := task.runProbe(pm.probe)
result := pm.runNow(task)
pm.startMonitor(task)
return result, nil
}
@@ -127,6 +128,19 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
return nil, nil
}
// runNow runs a probe and any due certificate check concurrently, so the
// response fits within the hub's single probe timeout budget.
func (pm *MonitorManager) runNow(task *monitorTask) *monitor.Result {
var wg sync.WaitGroup
wg.Go(func() { task.refreshCert(pm.certCheck) })
result := task.runProbe(pm.probe)
wg.Wait()
if result != nil {
result.Cert = task.certInfo()
}
return result
}
// DeleteMonitor stops and removes a single monitor task.
func (pm *MonitorManager) DeleteMonitor(id string) {
if id == "" {
@@ -158,6 +172,11 @@ func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Resul
if !ok {
continue
}
// Only the default interval updates monitor records on the hub, so
// realtime requests must not consume the unsent certificate.
if durationMs == defaultDataCacheTimeMs {
result.Cert = task.takeUnsentCert()
}
results[task.config.ID] = result
}

View File

@@ -0,0 +1,74 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const (
certCheckInterval = 24 * time.Hour
certCheckRetryInterval = time.Hour
)
// certChecker fetches the leaf certificate for an HTTPS target.
type certChecker func(context.Context, string) (monitor.CertInfo, error)
// certCheckEnabled reports whether a monitor's certificate is checked, which is
// the case for every HTTP monitor with an https target.
func certCheckEnabled(config monitor.Config) bool {
return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://")
}
// checkCert reads the leaf certificate presented by an HTTPS target. The chain is
// not verified, so expired or self-signed certificates are still reported.
func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) {
address, host, err := certAddress(target)
if err != nil {
return monitor.CertInfo{}, err
}
ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout)
defer cancel()
dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return monitor.CertInfo{}, err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return monitor.CertInfo{}, errors.New("no peer certificates")
}
leaf := certs[0]
return monitor.CertInfo{
Expires: leaf.NotAfter.UnixMilli(),
Issuer: leaf.Issuer.CommonName,
}, nil
}
// certAddress returns the dial address and server name for an HTTPS URL.
func certAddress(target string) (address, host string, err error) {
u, err := url.Parse(target)
if err != nil {
return "", "", err
}
if !strings.EqualFold(u.Scheme, "https") {
return "", "", fmt.Errorf("certificate check requires an https target: %s", target)
}
host = u.Hostname()
if host == "" {
return "", "", fmt.Errorf("missing host in target: %s", target)
}
port := u.Port()
if port == "" {
port = "443"
}
return net.JoinHostPort(host, port), host, nil
}

View File

@@ -0,0 +1,184 @@
//go:build testing
package agent
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckCertReadsUnverifiedLeaf(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
// httptest uses a self-signed certificate, which must still be reported.
info, err := checkCert(context.Background(), server.URL)
require.NoError(t, err)
leaf := server.Certificate()
assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires)
assert.Equal(t, leaf.Issuer.CommonName, info.Issuer)
}
func TestCertAddress(t *testing.T) {
tests := []struct {
target, address, host string
wantErr bool
}{
{target: "https://example.com", address: "example.com:443", host: "example.com"},
{target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"},
{target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"},
{target: "http://example.com", wantErr: true},
{target: "https://", wantErr: true},
}
for _, tt := range tests {
address, host, err := certAddress(tt.target)
if tt.wantErr {
assert.Error(t, err, tt.target)
continue
}
require.NoError(t, err, tt.target)
assert.Equal(t, tt.address, address)
assert.Equal(t, tt.host, host)
}
}
func TestRefreshCertCadence(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
var fail error
// Far enough out that the regular interval applies for the whole test.
expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
if fail != nil {
return monitor.CertInfo{}, fail
}
return monitor.CertInfo{Expires: expires + int64(calls)}, nil
}
task.refreshCert(check)
require.NotNil(t, task.certInfo())
assert.Equal(t, expires+1, task.certInfo().Expires)
// Not due again until the check interval passes.
time.Sleep(certCheckInterval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
// Failures keep the last known certificate and retry sooner.
fail = errors.New("connection refused")
time.Sleep(certCheckInterval)
task.refreshCert(check)
assert.Equal(t, 3, calls)
assert.Equal(t, expires+2, task.certInfo().Expires)
time.Sleep(certCheckRetryInterval)
fail = nil
task.refreshCert(check)
assert.Equal(t, 4, calls)
assert.Equal(t, expires+4, task.certInfo().Expires)
})
}
func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) {
for _, tc := range []struct {
name string
expires time.Duration // relative to the check
interval time.Duration
}{
{"expired", -time.Hour, certCheckRetryInterval},
{"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval},
{"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval},
} {
t.Run(tc.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil
}
task.refreshCert(check)
time.Sleep(tc.interval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
})
})
}
}
func TestCertCheckEnabled(t *testing.T) {
tests := []struct {
protocol, target string
want bool
}{
{"http", "https://example.com", true},
{"http", "HTTPS://example.com:8443/path", true},
{"http", "http://example.com", false},
{"http", "https://", false},
{"tcp", "https://example.com", false},
{"icmp", "example.com", false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target)
}
}
func TestRefreshCertSkipsNonHTTPS(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"})
defer task.cancel()
task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) {
t.Fatal("certificate check must not run for non-https targets")
return monitor.CertInfo{}, nil
})
assert.Nil(t, task.certInfo())
}
func TestUpsertMonitorRunNowIncludesCert(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil })
defer pm.Stop()
config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60}
result, err := pm.UpsertMonitor(config, true)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Cert)
assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires)
// Realtime results never carry the certificate, and the default interval
// sends it only once per check.
assert.Nil(t, pm.GetResults(1000)["cert"].Cert)
results := pm.GetResults(defaultDataCacheTimeMs)
require.NotNil(t, results["cert"].Cert)
assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires)
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
// Changing the interval keeps the known certificate without resending it.
config.Interval = 30
_, err = pm.UpsertMonitor(config, false)
require.NoError(t, err)
pm.mu.RLock()
task := pm.monitors["cert"]
pm.mu.RUnlock()
assert.NotNil(t, task.certInfo())
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
}

View File

@@ -8,9 +8,12 @@ import (
"net/http"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://beszel.dev)"
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
// Implementations must honor cancellation and bound their execution time.
type monitorProbe func(context.Context, monitor.Config) (int64, error)
@@ -25,7 +28,7 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
case "http":
return monitorHTTP(ctx, client, config.Target)
case "dns":
return monitorDNS(ctx, config.Target)
return monitorDNS(ctx, config.Target, config.Server)
default:
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
}
@@ -70,19 +73,43 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
return -1, err
}
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target string) (int64, error) {
// monitorDNS measures DNS resolution response time in microseconds. If server is
// non-empty, the lookup is sent to that DNS server (host or host:port, default
// port 53) instead of the system resolver. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target, server string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resolver := net.DefaultResolver
if server != "" {
resolver = dnsResolverForServer(server)
}
start := time.Now()
ips, err := net.DefaultResolver.LookupHost(ctx, target)
ips, err := resolver.LookupHost(ctx, target)
if err != nil || len(ips) == 0 {
return -1, err
}
return time.Since(start).Microseconds(), nil
}
// dnsResolverForServer builds a resolver that sends lookups to the given DNS
// server address instead of the system resolver. server may be a bare host or
// host:port; when no port is given, the standard DNS port 53 is used.
func dnsResolverForServer(server string) *net.Resolver {
address := server
if _, _, err := net.SplitHostPort(server); err != nil {
address = net.JoinHostPort(server, "53")
}
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, address)
},
}
}
// monitorHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
if client == nil {
@@ -93,6 +120,7 @@ func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, e
if err != nil {
return -1, err
}
req.Header.Set("User-Agent", networkMonitorUserAgent)
resp, err := client.Do(req)
if err != nil {
return -1, err

View File

@@ -14,9 +14,12 @@ func (pm *MonitorManager) startMonitor(task *monitorTask) {
}
delay := getStagger(interval.Milliseconds())
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
// Certificate checks piggyback on probe ticks, so they run at most once per
// probe interval after they become due.
go runMonitorSchedule(task.ctx, interval, delay, func() {
if _, allowed := task.resumeGuard.snapshot(); allowed {
task.runProbe(pm.probe)
task.refreshCert(pm.certCheck)
}
})
}

View File

@@ -21,6 +21,12 @@ type monitorTask struct {
runMu sync.Mutex
inflight *monitorRun
lastFailureLog int64 // Unix nanoseconds
certMu sync.Mutex
cert *monitor.CertInfo
certUnsent bool // cert has not been included in a stats result yet
certChecking bool
nextCertCheck time.Time
}
type monitorRun struct {
@@ -45,6 +51,11 @@ func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *m
task := newMonitorTask(config)
if existing != nil {
task.history = existing.history.clone()
// Keep the last known certificate, but check again soon for the new config.
// The hub already stores it, so it is not marked unsent.
if config.Target == existing.config.Target {
task.cert = existing.certInfo()
}
}
return task
}
@@ -107,6 +118,70 @@ func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
return copyMonitorResult(run.result)
}
// refreshCert checks the certificate of an HTTPS target when due. A failed
// check keeps the last known certificate and retries sooner, as does a
// certificate that expires before the next regular check, so renewals show up
// quickly. Concurrent callers skip rather than wait, and no lock is held during
// network I/O.
func (task *monitorTask) refreshCert(check certChecker) {
if check == nil || !certCheckEnabled(task.config) {
return
}
task.certMu.Lock()
if task.certChecking || time.Now().Before(task.nextCertCheck) {
task.certMu.Unlock()
return
}
task.certChecking = true
task.certMu.Unlock()
info, err := check(task.ctx, task.config.Target)
task.certMu.Lock()
defer task.certMu.Unlock()
task.certChecking = false
if task.ctx.Err() != nil {
return
}
if err != nil {
task.nextCertCheck = time.Now().Add(certCheckRetryInterval)
slog.Warn("certificate check failed", "err", err, "target", task.config.Target)
return
}
task.cert = &info
task.certUnsent = true
now := time.Now()
interval := certCheckInterval
if time.UnixMilli(info.Expires).Before(now.Add(certCheckInterval)) {
interval = certCheckRetryInterval
}
task.nextCertCheck = now.Add(interval)
}
// certInfo returns a copy of the latest certificate info, or nil if unknown.
func (task *monitorTask) certInfo() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if task.cert == nil {
return nil
}
cert := *task.cert
return &cert
}
// takeUnsentCert returns the latest certificate info once after each successful
// check, so unchanged info is not resent with every stats result.
func (task *monitorTask) takeUnsentCert() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if !task.certUnsent {
return nil
}
task.certUnsent = false
cert := *task.cert
return &cert
}
func copyMonitorResult(result *monitor.Result) *monitor.Result {
if result == nil {
return nil

View File

@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -240,6 +241,7 @@ func TestMonitorManagerGetRandomDelay(t *testing.T) {
func TestMonitorHTTP(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Beszel-Agent/"+beszel.Version+" (+https://beszel.dev)", r.Header.Get("User-Agent"))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
@@ -374,15 +376,79 @@ func tcpMonitorTestResolver(ips []string) *net.Resolver {
}}
}
// udpDNSTestServer starts a UDP server on loopback that answers A queries with the
// given IPs, and returns its listen address (host:port).
func udpDNSTestServer(t *testing.T, ips []string) string {
t.Helper()
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
go func() {
buf := make([]byte, 512)
for {
n, addr, err := conn.ReadFromUDP(buf)
if err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(buf[:n]); err != nil {
continue
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
for _, question := range msg.Questions {
if question.Type != dnsmessage.TypeA {
continue
}
for _, ip := range ips {
msg.Answers = append(msg.Answers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
})
}
}
packet, err := msg.Pack()
if err != nil {
continue
}
_, _ = conn.WriteToUDP(packet, addr)
}
}()
return conn.LocalAddr().String()
}
func TestMonitorDNS(t *testing.T) {
t.Run("success", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "localhost")
responseUs, err := monitorDNS(context.Background(), "localhost", "")
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("lookup failure", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "")
responseUs, err := monitorDNS(context.Background(), "", "")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
t.Run("custom server", func(t *testing.T) {
serverAddr := udpDNSTestServer(t, []string{"192.0.2.10"})
responseUs, err := monitorDNS(context.Background(), "example.test.", serverAddr)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("custom server without port defaults to 53", func(t *testing.T) {
resolver := dnsResolverForServer("127.0.0.1")
conn, err := resolver.Dial(context.Background(), "udp", "")
require.NoError(t, err)
defer conn.Close()
assert.Equal(t, "127.0.0.1:53", conn.RemoteAddr().String())
})
t.Run("custom server unreachable", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "example.test.", "127.0.0.1:1")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
@@ -477,7 +543,7 @@ func TestMonitorResolutionCancellation(t *testing.T) {
case "tcp":
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
case "dns":
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.", "")
case "icmp":
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
}

310
agent/package_updates.go Normal file
View File

@@ -0,0 +1,310 @@
package agent
import (
"bufio"
"context"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
)
const (
defaultPackageUpdatesInterval = time.Hour
packageUpdatesTimeout = 5 * time.Minute
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
)
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
// packageUpdatesManager periodically checks the host package manager for pending
// updates in the background and caches the result, so checks never delay metrics.
type packageUpdatesManager struct {
sync.Mutex
check packageUpdatesCheck
interval time.Duration
counts []uint16
checkedAt time.Time
running bool
}
// newPackageUpdatesManager returns nil if disabled or no supported package manager
// is found. Agents running in a container are skipped because the container's
// package database is not the host's. dataDir holds pacman's private sync databases.
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
if runtime.GOOS != "linux" || runningInContainer() {
return nil
}
interval := defaultPackageUpdatesInterval
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
return nil
case err == nil && duration > 0:
interval = duration
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
}
}
name, check := detectPackageManager(dataDir)
if check == nil {
return nil
}
slog.Debug("Package updates", "manager", name, "interval", interval)
return &packageUpdatesManager{check: check, interval: interval}
}
// get returns the last cached counts and starts a background check if they are stale.
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
pm.Lock()
defer pm.Unlock()
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
pm.running = true
go pm.refresh()
}
return pm.counts
}
func (pm *packageUpdatesManager) refresh() {
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
defer cancel()
counts, err := pm.check(ctx)
if err != nil {
slog.Debug("Package updates check failed", "err", err)
counts = nil
}
pm.Lock()
pm.counts = counts
pm.checkedAt = time.Now()
pm.running = false
pm.Unlock()
}
func runningInContainer() bool {
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(path); err == nil {
return true
}
}
return false
}
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
switch {
case commandExists("apt-get"):
return "apt", checkApt
case commandExists("dnf"):
return "dnf", checkDnf
case commandExists("zypper"):
return "zypper", checkZypper
case commandExists("checkupdates"):
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
}
return "", nil
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// runPackageCommand runs a read-only package manager command and returns stdout.
// okCodes lists non-zero exit codes that still mean success.
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
}
// runPackageCommandEnv is runPackageCommand with extra environment variables.
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
cmd.Env = append(cmd.Env, env...)
// checkupdates is a shell script, so a timeout kills only the script and its
// children can keep stdout open. WaitDelay stops Output from waiting on them.
cmd.WaitDelay = 10 * time.Second
out, err := cmd.Output()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) {
return string(out), nil
}
return string(out), err
}
// checkApt simulates a full upgrade against the current package lists.
// It never refreshes the lists; apt-daily or the user does that.
func checkApt(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
if err != nil {
return nil, err
}
total, security := parseAptSimulate(out)
return []uint16{total, security}, nil
}
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
func checkDnf(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
if err != nil {
return nil, err
}
total := parseDnfCheckUpdate(out)
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
if err != nil {
return []uint16{total}, nil
}
return []uint16{total, parseDnfCheckUpdate(out)}, nil
}
func checkZypper(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
if err != nil {
return nil, err
}
total := parseZypperTable(out)
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
if err != nil {
return []uint16{total}, nil
}
return []uint16{total, parseZypperTable(out)}, nil
}
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
// the databases and never touches pacman's own. The copy lives in dataDir because
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
// Local upgrades show up right away since checkupdates links the live local DB.
// Exit code 2 means no updates.
func newPacmanCheck(dataDir string) packageUpdatesCheck {
var env []string
var syncDir string
if dataDir != "" {
dbPath := filepath.Join(dataDir, "checkup-db")
env = []string{"CHECKUPDATES_DB=" + dbPath}
syncDir = filepath.Join(dbPath, "sync")
}
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
return func(ctx context.Context) ([]uint16, error) {
// -n with a missing database reports no updates rather than failing,
// so always sync first and whenever the private copy is missing
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
if !sync && syncDir != "" {
if _, err := os.Stat(syncDir); err != nil {
sync = true
}
}
var args []string
if !sync {
args = append(args, "-n")
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return nil, err
}
if sync {
lastSync = time.Now()
}
return []uint16{parsePacmanCheckUpdates(out)}, nil
}
}
func checkApk(ctx context.Context) ([]uint16, error) {
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
if err != nil {
return nil, err
}
return []uint16{parseApkUpgradable(out)}, nil
}
// parseAptSimulate counts upgrades in `apt-get -s` output. Upgrade lines look like
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
// New dependencies have no "[old version]" and are not counted.
func parseAptSimulate(out string) (total, security uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") {
continue
}
total++
start := strings.IndexByte(line, '(')
end := strings.IndexByte(line, ')')
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
security++
}
}
return total, security
}
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not counted twice.
func parseDnfCheckUpdate(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "Obsoleting") {
break
}
fields := strings.Fields(line)
if len(fields) == 3 && strings.Contains(fields[0], ".") {
count++
}
}
return count
}
// parseZypperTable counts the data rows of a zypper table (the lines after the
// "---+---" separator).
func parseZypperTable(out string) (count uint16) {
inTable := false
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
switch {
case !inTable:
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
case strings.Contains(line, "|"):
count++
default:
return count
}
}
return count
}
// parsePacmanCheckUpdates counts "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
if strings.Contains(scanner.Text(), " -> ") {
count++
}
}
return count
}
// parseApkUpgradable counts lines of `apk -u list`, which look like
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
func parseApkUpgradable(out string) (count uint16) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
if strings.Contains(scanner.Text(), "[upgradable from:") {
count++
}
}
return count
}

View File

@@ -0,0 +1,196 @@
//go:build testing
package agent
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readPackageUpdatesTestData(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join("test-data", "package_updates", name))
require.NoError(t, err)
return string(data)
}
// Test data files are real command outputs captured in containers.
func TestParseAptSimulate(t *testing.T) {
tests := []struct {
file string
total, security uint16
}{
{"apt_debian12.txt", 44, 5},
{"apt_ubuntu2204.txt", 58, 45},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Equal(t, tt.total, total)
assert.Equal(t, tt.security, security)
})
}
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Remv oldpkg [1.0]`
total, security := parseAptSimulate(out)
assert.Equal(t, uint16(2), total)
assert.Equal(t, uint16(1), security)
})
t.Run("no updates", func(t *testing.T) {
total, security := parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n")
assert.Zero(t, total)
assert.Zero(t, security)
})
}
func TestParseDnfCheckUpdate(t *testing.T) {
tests := []struct {
file string
count uint16
}{
{"dnf4_rocky9_check_update.txt", 110},
{"dnf4_rocky9_check_update_security.txt", 53},
{"dnf5_fedora42_check_update.txt", 20},
{"dnf5_fedora42_check_update_security.txt", 5},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Equal(t, tt.count, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)))
})
}
t.Run("obsoletes section and notices", func(t *testing.T) {
out := `
kernel.x86_64 5.14.0-503.el9 baseos
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
Obsoleting Packages
grub2-tools.x86_64 1:2.06-80.el9 baseos
grub2-tools.x86_64 1:2.06-77.el9 @baseos
`
assert.Equal(t, uint16(1), parseDnfCheckUpdate(out))
})
}
func TestParseZypperTable(t *testing.T) {
tests := []struct {
file string
count uint16
}{
{"zypper_leap155_list_updates.txt", 22},
{"zypper_leap155_list_patches_security.txt", 4},
{"zypper_leap156_list_updates_none.txt", 0},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Equal(t, tt.count, parseZypperTable(readPackageUpdatesTestData(t, tt.file)))
})
}
}
func TestParsePacmanCheckUpdates(t *testing.T) {
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Zero(t, parsePacmanCheckUpdates(""))
}
func TestParseApkUpgradable(t *testing.T) {
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
assert.Zero(t, parseApkUpgradable(""))
}
func TestPackageUpdatesManagerCaching(t *testing.T) {
calls := make(chan struct{}, 10)
result := []uint16{3, 1}
var resultErr error
pm := &packageUpdatesManager{
interval: time.Hour,
check: func(context.Context) ([]uint16, error) {
calls <- struct{}{}
return result, resultErr
},
}
waitIdle := func() {
require.Eventually(t, func() bool {
pm.Lock()
defer pm.Unlock()
return !pm.running
}, time.Second, time.Millisecond)
}
now := time.Now()
// first call starts a background check and returns nothing yet
assert.Nil(t, pm.get(now))
waitIdle()
assert.Len(t, calls, 1)
// cached result within interval, no new check
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
assert.Len(t, calls, 1)
// stale after interval: returns cached value and refreshes in background
result, resultErr = nil, errors.New("boom")
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
waitIdle()
assert.Len(t, calls, 2)
// failed check clears the counts
assert.Nil(t, pm.get(time.Now()))
}
func TestPacmanCheckSync(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a shell script on PATH")
}
binDir := t.TempDir()
dataDir := t.TempDir()
logFile := filepath.Join(binDir, "calls.log")
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
script := `#!/bin/sh
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
echo "linux 6.1-1 -> 6.2-1"
`
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
check := newPacmanCheck(dataDir)
dbPath := filepath.Join(dataDir, "checkup-db")
readCalls := func() []string {
data, err := os.ReadFile(logFile)
require.NoError(t, err)
return strings.Split(strings.TrimSpace(string(data)), "\n")
}
// first check syncs
counts, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, counts)
// later checks reuse the synced copy
_, err = check(context.Background())
require.NoError(t, err)
// a missing private copy forces a sync
require.NoError(t, os.RemoveAll(dbPath))
_, err = check(context.Background())
require.NoError(t, err)
assert.Equal(t, []string{
"args=[] db=" + dbPath,
"args=[-n] db=" + dbPath,
"args=[] db=" + dbPath,
}, readCalls())
}

View File

@@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
@@ -32,6 +34,8 @@ type SensorConfig struct {
isBlacklist bool
hasWildcards bool
skipCollection bool
skipGPU bool
sensorShadow string
firstRun bool
}
@@ -41,13 +45,14 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
skipGPU, _ := utils.GetEnv("SKIP_GPU")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
}
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig {
timeout := 2 * time.Second
if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
@@ -62,6 +67,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
primarySensor: primarySensor,
timeout: timeout,
skipCollection: skipCollection,
skipGPU: skipGPU,
firstRun: true,
sensors: make(map[string]struct{}),
}
@@ -73,6 +79,19 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
if skipGPU && runtime.GOOS == "linux" {
// gopsutil reads every temp*_input before results can be filtered, so
// point it at a shadow tree built from the effective sysfs root instead.
if shadow, err := buildNonGpuSysShadow(effectiveSysRoot(config.context)); err == nil {
slog.Info("SKIP_GPU enabled, using non-GPU sensor sysfs shadow", "path", shadow)
config.sensorShadow = shadow
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: shadow},
)
} else {
slog.Warn("SKIP_GPU sensor shadow unavailable, falling back to post-read filtering", "err", err)
}
}
// handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") {
@@ -149,6 +168,9 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
if !isValidSensor(sensorName, a.sensorConfig) {
continue
}
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
continue
}
// set dashboard temperature
switch a.sensorConfig.primarySensor {
case "":
@@ -245,3 +267,102 @@ func scaleTemperature(temp float64) float64 {
}
return scaled100
}
// effectiveSysRoot mirrors gopsutil's HostSys lookup, which lives in its
// internal package: context override, then HOST_SYS env, then /sys.
func effectiveSysRoot(ctx context.Context) string {
if envMap, ok := ctx.Value(common.EnvKey).(common.EnvMap); ok {
if v := envMap[common.HostSysEnvKey]; v != "" {
return v
}
}
if v := os.Getenv("HOST_SYS"); v != "" {
return v
}
return "/sys"
}
func (config *SensorConfig) cleanupSensorShadow() {
if config.sensorShadow == "" {
return
}
if err := os.RemoveAll(config.sensorShadow); err != nil {
slog.Warn("Error removing sensor sysfs shadow", "path", config.sensorShadow, "err", err)
return
}
config.sensorShadow = ""
}
func (a *Agent) cleanupSensorShadow() {
if a.sensorConfig != nil {
a.sensorConfig.cleanupSensorShadow()
}
}
func isGpuThermalZone(zoneType string) bool {
zoneType = strings.ToLower(strings.TrimSpace(zoneType))
return isGpuChipName(zoneType) || strings.Contains(zoneType, "gpu")
}
// buildNonGpuSysShadow links non-GPU sensor directories into a temp dir. Only
// static chip names and thermal-zone types are read; no sensor values are touched.
func buildNonGpuSysShadow(sysRoot string) (string, error) {
shadow, err := os.MkdirTemp("", "beszel-sensors-*")
if err != nil {
return "", err
}
shadowHwmon := filepath.Join(shadow, "class", "hwmon")
if err := os.MkdirAll(shadowHwmon, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
entries, err := os.ReadDir(filepath.Join(sysRoot, "class", "hwmon"))
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range entries {
chipDir := filepath.Join(sysRoot, "class", "hwmon", entry.Name())
// Some hwmon devices expose name under device/ (gopsutil's CentOS fallback).
name, ok := utils.ReadStringFileOK(filepath.Join(chipDir, "name"))
if !ok {
name, ok = utils.ReadStringFileOK(filepath.Join(chipDir, "device", "name"))
}
if !ok || isGpuChipName(name) {
continue
}
if err := os.Symlink(chipDir, filepath.Join(shadowHwmon, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
thermalEntries, err := os.ReadDir(filepath.Join(sysRoot, "class", "thermal"))
if err != nil {
if os.IsNotExist(err) {
return shadow, nil
}
os.RemoveAll(shadow)
return "", err
}
shadowThermal := filepath.Join(shadow, "class", "thermal")
if err := os.MkdirAll(shadowThermal, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range thermalEntries {
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
continue
}
zoneDir := filepath.Join(sysRoot, "class", "thermal", entry.Name())
zoneType, ok := utils.ReadStringFileOK(filepath.Join(zoneDir, "type"))
if !ok || isGpuThermalZone(zoneType) {
continue
}
if err := os.Symlink(zoneDir, filepath.Join(shadowThermal, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
return shadow, nil
}

View File

@@ -5,6 +5,8 @@ package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
@@ -328,7 +330,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
// Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -620,3 +622,143 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures)
}
func TestIsGpuSensorKey(t *testing.T) {
for _, key := range []string{"xe", "XE_temp1", "amdgpu_edge", "NVIDIA"} {
assert.True(t, isGpuSensorKey(key), key)
}
for _, key := range []string{"coretemp_core_0", "acpitz", "xen_temp", "myxe", ""} {
assert.False(t, isGpuSensorKey(key), key)
}
}
func TestSkipGpuSensorShadow(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestSkipGpuSensorShadowDeviceName(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "temp1_input"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "device", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestSkipGpuSensorShadowKeepsThermalZonesWithoutNonGpuHwmon(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
hwmonTemps, err := filepath.Glob(filepath.Join(shadow, "class", "hwmon", "hwmon*", "temp*_input"))
require.NoError(t, err)
assert.Empty(t, hwmonTemps)
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestNewSensorConfigSkipGpuWiresShadow(t *testing.T) {
t.Setenv("SKIP_GPU", "true")
agent := &Agent{}
config := agent.newSensorConfig()
assert.True(t, config.skipGPU)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
assert.DirExists(t, filepath.Join(shadow, "class", "hwmon"))
assert.Equal(t, shadow, config.sensorShadow)
config.cleanupSensorShadow()
assert.NoDirExists(t, shadow)
assert.Empty(t, config.sensorShadow)
}
func TestSkipGpuShadowUsesSysSensorsRoot(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
agent := &Agent{}
config := agent.newSensorConfigWithEnv("", sysRoot, "", "", false, true)
t.Cleanup(config.cleanupSensorShadow)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
require.NotEqual(t, sysRoot, shadow, "shadow must not be the SYS_SENSORS tree itself")
target, err := os.Readlink(filepath.Join(shadow, "class", "hwmon", "hwmon0"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0"), target)
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestUpdateTemperaturesSkipGpu(t *testing.T) {
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "coretemp_core_0", Temperature: 55},
{SensorKey: "XE", Temperature: 48},
}, nil
}
newAgent := func(skipGPU bool) *Agent {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
skipGPU: skipGPU,
},
}
return agent
}
stats := &system.Stats{}
newAgent(true).updateTemperatures(stats)
assert.Equal(t, map[string]float64{"coretemp_core_0": 55}, stats.Temperatures)
stats = &system.Stats{}
newAgent(false).updateTemperatures(stats)
assert.Len(t, stats.Temperatures, 2)
}

View File

@@ -54,12 +54,18 @@ type poolBackend struct {
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
// Utility-backed caches below are refreshed in the background after the
// first collection, so cacheMu guards them against those goroutines.
cacheMu sync.Mutex
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
kernelSamples map[string]poolKernelSample
poolRefreshing bool
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
usageRefreshing bool
kernelSamples map[string]poolKernelSample
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
// an interval. Accessed from handler goroutines, so it is mutex-protected.
@@ -177,11 +183,34 @@ func (b *poolBackend) updateBackendStats(systemStats *system.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.
// every poolStatsRefreshInterval. Only the first collection blocks; later
// refreshes run in the background because utilities like `zpool list` can hang
// for seconds on busy hosts, which would otherwise delay the hub's stats
// response. 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 {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
return b.poolData
}
if b.lastPoolStats.IsZero() {
b.storePoolStats(b.poolStatsFn())
return b.poolData
}
b.poolRefreshing = true
go func() {
pools, err := b.poolStatsFn()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.poolRefreshing = false
b.storePoolStats(pools, err)
}()
return b.poolData
}
// storePoolStats records a pool inventory result. Callers must hold cacheMu.
func (b *poolBackend) storePoolStats(pools []zfs.PoolStat, err error) {
if err != nil {
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
} else {
@@ -189,8 +218,6 @@ func (b *poolBackend) poolStats() []zfs.PoolStat {
}
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
@@ -225,12 +252,33 @@ func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]z
}
// 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
// and returns the mountpoint-keyed usage map. Like poolStats, only the first
// collection blocks and later refreshes run in the background.
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
return b.datasetUsage
}
if b.lastUsageRefresh.IsZero() {
b.storeDatasetUsage(b.datasets())
return b.datasetUsage
}
b.usageRefreshing = true
go func() {
datasets, err := b.datasets()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.usageRefreshing = false
b.storeDatasetUsage(datasets, err)
}()
return b.datasetUsage
}
// storeDatasetUsage rebuilds the usage map from a dataset listing. The map is
// replaced rather than mutated so returned references stay safe to read.
// Callers must hold cacheMu.
func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
if err != nil {
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
} else {
@@ -251,8 +299,7 @@ func (b *poolBackend) refreshDatasetUsage() {
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
for _, backend := range m.backends {
if backend.name == "zfs" {
backend.refreshDatasetUsage()
return backend.datasetUsage
return backend.refreshDatasetUsage()
}
}
return nil
@@ -442,7 +489,10 @@ func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystem
}
}
for _, backend := range m.backends {
for _, pool := range backend.poolData {
backend.cacheMu.Lock()
pools := backend.poolData
backend.cacheMu.Unlock()
for _, pool := range pools {
sample := stats.ZfsPools[pool.Name]
if sample == nil || pool.MountID == "" {
continue

View File

@@ -518,3 +518,31 @@ func TestBtrfsPoolIdentities(t *testing.T) {
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
}
func TestStaleUtilityCachesRefreshInBackground(t *testing.T) {
release := make(chan struct{})
b := &poolBackend{name: "zfs"}
b.poolStatsFn = func() ([]zfs.PoolStat, error) {
<-release
return []zfs.PoolStat{{Name: "new"}}, nil
}
b.datasetsFn = func() ([]zfs.Dataset, error) {
<-release
return []zfs.Dataset{{Name: "new", Mountpoint: "/new"}}, nil
}
b.poolData = []zfs.PoolStat{{Name: "old"}}
b.lastPoolStats = time.Now().Add(-2 * poolStatsRefreshInterval)
b.datasetUsage = map[string]zfsDatasetUsage{"/old": {}}
b.lastUsageRefresh = time.Now().Add(-2 * datasetUsageRefreshInterval)
// A hung utility must not block collection; cached data is served meanwhile.
for range 2 {
assert.Equal(t, "old", b.poolStats()[0].Name)
assert.Contains(t, b.refreshDatasetUsage(), "/old")
}
close(release)
require.Eventually(t, func() bool {
return b.poolStats()[0].Name == "new" && b.refreshDatasetUsage()["/new"] == zfsDatasetUsage{}
}, time.Second, time.Millisecond)
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/wifi"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -267,6 +268,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
}
// Wi-Fi collection spawns a process on macOS and dumps the BSS cache on
// Linux, so only refresh on the default interval. Real-time requests reuse
// the last snapshot.
if cacheTimeMs == defaultDataCacheTimeMs {
a.systemInfo.WiFi = wifi.Collect()
}
systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi)
// update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu

View File

@@ -0,0 +1,10 @@
apk-tools-2.14.4-r1 aarch64 {apk-tools} (GPL-2.0-only) [upgradable from: apk-tools-2.14.4-r0]
busybox-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r28]
busybox-binsh-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-binsh-1.36.1-r28]
ca-certificates-bundle-20260413-r0 aarch64 {ca-certificates} (MPL-2.0 AND MIT) [upgradable from: ca-certificates-bundle-20240226-r0]
libcrypto3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.0-r2]
libssl3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.3.0-r2]
musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]
musl-utils-1.2.5-r3 aarch64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [upgradable from: musl-utils-1.2.5-r0]
ssl_client-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: ssl_client-1.36.1-r28]
zlib-1.3.2-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.1-r1]

View File

@@ -0,0 +1,101 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
base-files bash bsdutils debian-archive-keyring dpkg e2fsprogs gcc-12-base
gpgv init-system-helpers libblkid1 libc-bin libc6 libcap2 libcom-err2
libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 liblzma5 libmount1
libpam-modules libpam-modules-bin libpam-runtime libpam0g libpcre2-8-0
libseccomp2 libsmartcols1 libss2 libstdc++6 libsystemd0 libtasn1-6 libudev1
libuuid1 login logsave mount passwd perl-base sed tar tzdata usr-is-merged
util-linux util-linux-extra
44 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst base-files [12.4+deb12u4] (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Conf base-files (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Inst bash [5.2.15-2+b2] (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Conf bash (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Inst bsdutils [1:2.38.1-5+b1] (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf bsdutils (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tar [1.34+dfsg-1.2] (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Conf tar (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Inst dpkg [1.21.22] (1.21.23 Debian:12.15/oldstable [arm64])
Conf dpkg (1.21.23 Debian:12.15/oldstable [arm64])
Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf login (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst perl-base [5.36.0-7+deb12u1] (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Conf perl-base (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Inst sed [4.9-1] (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf sed (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst gcc-12-base [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Conf libstdc++6 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Inst libc6 [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc6 (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libsmartcols1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libsmartcols1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux-extra [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux-extra (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst usr-is-merged [35] (37~deb12u1 Debian:12.15/oldstable [all])
Conf usr-is-merged (37~deb12u1 Debian:12.15/oldstable [all])
Inst init-system-helpers [1.65.2] (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Conf init-system-helpers (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Inst libc-bin [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc-bin (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libpam0g [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam0g (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst libpam-modules-bin [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam-modules (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst logsave [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libext2fs2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst mount [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpam-runtime [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Conf libpam-runtime (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf passwd (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst debian-archive-keyring [2023.3+deb12u1] (2023.3+deb12u2 Debian:12.15/oldstable [all])
Conf debian-archive-keyring (2023.3+deb12u2 Debian:12.15/oldstable [all])
Inst libgcrypt20 [1.10.1-3] (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgcrypt20 (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst gpgv [2.2.40-1.1] (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Conf gpgv (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Inst libblkid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libblkid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libcap2 [1:2.66-4] (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Conf libcap2 (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Inst libtasn1-6 [4.19.0-2] (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libtasn1-6 (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst libgnutls30 [3.7.9-2+deb12u1] (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgnutls30 (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst liblzma5 [5.4.1-0.2] (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Conf liblzma5 (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Inst libmount1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libmount1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpcre2-8-0 [10.42-1] (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Conf libpcre2-8-0 (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Inst libseccomp2 [2.5.4-1+b3] (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf libseccomp2 (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst libsystemd0 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libsystemd0 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libudev1 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libudev1 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libuuid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libuuid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tzdata [2023c-5+deb12u1] (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Inst libcom-err2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libss2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf logsave (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf e2fsprogs (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf mount (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf tzdata (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Conf libcom-err2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf libss2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])

View File

@@ -0,0 +1,130 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
apt base-files bash bsdutils coreutils diffutils dpkg e2fsprogs gcc-12-base
gpgv gzip libapt-pkg6.0 libattr1 libblkid1 libbz2-1.0 libc-bin libc6 libcap2
libcom-err2 libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 libgssapi-krb5-2
libk5crypto3 libkrb5-3 libkrb5support0 liblzma5 libmount1 libncurses6
libncursesw6 libp11-kit0 libpam-modules libpam-modules-bin libpam-runtime
libpam0g libprocps8 libseccomp2 libsmartcols1 libss2 libssl3 libstdc++6
libsystemd0 libtasn1-6 libtinfo6 libudev1 libuuid1 login logsave mount
ncurses-base ncurses-bin passwd perl-base procps sed tar util-linux
58 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libstdc++6 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc6 (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst base-files [12ubuntu4.4] (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Conf base-files (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Inst bash [5.1-6ubuntu1] (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bash (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst bsdutils [1:2.37.2-4ubuntu3] (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bsdutils (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst coreutils [8.32-4.1ubuntu1] (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf coreutils (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst diffutils [1:3.8-0ubuntu2] (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf diffutils (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libbz2-1.0 [1.0.8-5build1] (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libbz2-1.0 (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgcrypt20 [1.9.4-3ubuntu3] (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgcrypt20 (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst liblzma5 [5.2.5-2ubuntu1] (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf liblzma5 (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libsystemd0 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsystemd0 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libudev1 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libudev1 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libapt-pkg6.0 [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf libapt-pkg6.0 (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst tar [1.34+dfsg-1ubuntu0.1.22.04.1] (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf tar (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst dpkg [1.21.1ubuntu2.2] (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf dpkg (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst gzip [1.10-4ubuntu4.1] (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gzip (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst login [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf login (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-bin [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf ncurses-bin (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst perl-base [5.34.0-3ubuntu1.2] (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf perl-base (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst sed [4.8-1ubuntu2] (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf sed (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst util-linux [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf util-linux (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc-bin [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc-bin (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-base [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf ncurses-base (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst gpgv [2.2.27-3ubuntu2.1] (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gpgv (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libp11-kit0 [0.24.0-6build1] (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libp11-kit0 (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libtasn1-6 [4.18.0-4build1] (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtasn1-6 (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgnutls30 [3.7.3-4ubuntu1.2] (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgnutls30 (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libseccomp2 [2.5.3-2ubuntu2] (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Conf libseccomp2 (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Inst apt [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf apt (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst libpam0g [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam0g (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-modules-bin [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam-modules (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst logsave [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libext2fs2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst mount [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libattr1 [1:2.5.1-1build1] (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libattr1 (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libblkid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libblkid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcap2 [1:2.44-1ubuntu0.22.04.1] (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libcap2 (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcom-err2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf libcom-err2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libk5crypto3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libk5crypto3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libkrb5support0 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Conf libkrb5support0 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Inst libkrb5-3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Conf libkrb5-3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Inst libgssapi-krb5-2 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libgssapi-krb5-2 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libssl3 (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libmount1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libmount1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-runtime [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf libpam-runtime (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst libsmartcols1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsmartcols1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libncurses6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libncursesw6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libtinfo6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtinfo6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libuuid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libuuid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst passwd [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf passwd (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libprocps8 [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libss2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst procps [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf logsave (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf e2fsprogs (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf mount (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncurses6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncursesw6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libprocps8 (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libss2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf procps (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])

View File

@@ -0,0 +1,111 @@
alternatives.aarch64 1.24-2.el9 baseos
audit-libs.aarch64 3.1.5-8.el9 baseos
basesystem.noarch 11-13.el9.0.1 baseos
bash.aarch64 5.1.8-9.el9 baseos
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
ca-certificates.noarch 2025.2.80_v9.0.305-91.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
cracklib.aarch64 2.9.6-28.el9 baseos
cracklib-dicts.aarch64 2.9.6-28.el9 baseos
crypto-policies.noarch 20260224-1.gitea0f072.el9 baseos
crypto-policies-scripts.noarch 20260224-1.gitea0f072.el9 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
cyrus-sasl-lib.aarch64 2.1.27-22.el9_7 baseos
dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
dnf-data.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
elfutils-debuginfod-client.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-default-yama-scope.noarch 0.194-1.el9.rocky.0.1 baseos
elfutils-libelf.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-libs.aarch64 0.194-1.el9.rocky.0.1 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
filesystem.aarch64 3.16-5.el9 baseos
findutils.aarch64 1:4.8.0-7.el9 baseos
gdbm-libs.aarch64 1:1.23-1.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
ima-evm-utils.aarch64 1.6.2-2.el9.rocky.0.2 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcom_err.aarch64 1.46.5-8.el9 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libdb.aarch64 5.3.28-57.el9_6 baseos
libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
libeconf.aarch64 0.4.1-7.el9_8 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libksba.aarch64 1.5.1-7.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
librepo.aarch64 1.19.0-1.el9 baseos
libselinux.aarch64 3.6-3.el9 baseos
libsemanage.aarch64 3.6-5.el9_6 baseos
libsepol.aarch64 3.6-3.el9 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libusbx.aarch64 1.0.30-1.el9_8 baseos
libuser.aarch64 0.63-17.el9 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
libzstd.aarch64 1.5.5-1.el9 baseos
mpfr.aarch64 4.1.0-10.el9 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
nettle.aarch64 3.10.1-1.el9 baseos
openldap.aarch64 2.6.8-4.el9.0.1 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
pcre.aarch64 8.44-4.el9 baseos
pcre2.aarch64 10.40-6.el9 baseos
pcre2-syntax.noarch 10.40-6.el9 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
python3-hawkey.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-pip-wheel.noarch 21.3.1-2.el9_8.rocky.0.1 baseos
python3-rpm.aarch64 4.16.1.3-40.el9 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
rocky-gpg-keys.noarch 9.8-1.2.el9 baseos
rocky-release.noarch 9.8-1.2.el9 baseos
rocky-repos.noarch 9.8-1.2.el9 baseos
rootfiles.noarch 8.1-35.el9 baseos
rpm.aarch64 4.16.1.3-40.el9 baseos
rpm-build-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-sign-libs.aarch64 4.16.1.3-40.el9 baseos
sed.aarch64 4.8-10.el9_8 baseos
setup.noarch 2.13.7-10.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
tpm2-tss.aarch64 3.2.3-1.el9 baseos
tzdata.noarch 2026c-1.el9_8 baseos
usermode.aarch64 1.114-7.el9 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
yum.noarch 4.14.0-34.el9_8.rocky.0.1 baseos

View File

@@ -0,0 +1,54 @@
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos

View File

@@ -0,0 +1,20 @@
dnf5.aarch64 5.2.18.0-3.fc42 updates
dnf5-plugins.aarch64 5.2.18.0-3.fc42 updates
elfutils-default-yama-scope.noarch 0.195-1.fc42 updates
elfutils-libelf.aarch64 0.195-1.fc42 updates
elfutils-libs.aarch64 0.195-1.fc42 updates
fedora-release-common.noarch 42-31 updates
fedora-release-container.noarch 42-31 updates
fedora-release-identity-container.noarch 42-31 updates
glibc.aarch64 2.41-18.fc42 updates
glibc-common.aarch64 2.41-18.fc42 updates
glibc-minimal-langpack.aarch64 2.41-18.fc42 updates
krb5-libs.aarch64 1.21.3-7.fc42 updates
libdnf5.aarch64 5.2.18.0-3.fc42 updates
libdnf5-cli.aarch64 5.2.18.0-3.fc42 updates
libsolv.aarch64 0.7.37-2.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
tzdata.noarch 2026b-1.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates

View File

@@ -0,0 +1,5 @@
krb5-libs.aarch64 1.21.3-7.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates

View File

@@ -0,0 +1,4 @@
libpcap 1.10.7-1 -> 1.11.0-1
libsecret 0.21.7-1 -> 0.21.8.2-1
libtirpc 1.3.7-1 -> 1.3.8-1
tzdata 2026c-1 -> 2026d-1

View File

@@ -0,0 +1,15 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
Repository | Name | Category | Severity | Interactive | Status | Summary
-------------------------------------------------------------+-----------------------------+----------+-----------+-------------+--------+--------------------------------
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3765 | security | moderate | --- | needed | Security update for openssl-1_1
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3926 | security | moderate | --- | needed | Security update for curl
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4078 | security | important | --- | needed | Security update for glib2
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4359 | security | moderate | --- | needed | Security update for curl
4 patches needed (4 security patches)

View File

@@ -0,0 +1,29 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
S | Repository | Name | Current Version | Available Version | Arch
---+--------------------------------------------------------------+--------------------+------------------------------------------+------------------------------------------+--------
v | Update repository with updates from SUSE Linux Enterprise 15 | aaa_base | 84.87+git20180409.04c9dae-150300.10.20.1 | 84.87+git20180409.04c9dae-150300.10.23.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash-sh | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | crypto-policies | 20210917.c9d86d1-150400.3.6.1 | 20210917.c9d86d1-150400.3.8.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | curl | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | glibc | 2.31-150300.86.3 | 2.31-150300.89.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcom_err2 | 1.46.4-150400.3.6.2 | 1.46.4-150400.3.9.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcurl4 | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libgcc_s1 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libglib-2_0-0 | 2.70.5-150400.3.14.1 | 2.70.5-150400.3.17.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1-hmac | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libreadline7 | 7.0-150400.25.22 | 7.0-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools-base | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libstdc++6 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libudev1 | 249.17-150400.8.43.1 | 249.17-150400.8.46.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libzypp | 17.35.8-150500.6.13.1 | 17.35.16-150500.6.31.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | login_defs | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | openssl-1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | shadow | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | zypper | 1.14.76-150500.6.6.15 | 1.14.78-150500.6.14.1 | aarch64

View File

@@ -0,0 +1,3 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.

11
agent/wifi/README.md Normal file
View File

@@ -0,0 +1,11 @@
# Connected Wi-Fi signal
Reports connected station interfaces only (no scans). `info.wf` holds the current
snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
`stats.wf` stores available RSSI as integer dBm. Collected on the default
interval only; real-time requests reuse the last snapshot.
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
- Windows: native WLAN API, keyed by interface GUID.
- Other platforms: unsupported.

46
agent/wifi/wifi.go Normal file
View File

@@ -0,0 +1,46 @@
// Package wifi collects only currently associated station interfaces. Collection
// failures are empty snapshots, never cached connected state.
package wifi
import (
"context"
"math"
"time"
"unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system"
)
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
// text strings require UTF-8. Metadata must never invalidate the whole response.
func validSSID(ssid string) string {
if !utf8.ValidString(ssid) {
return ""
}
return ssid
}
// Collect uses a single deadline across interface queries where supported.
// Unsupported platforms and denied association access produce no readings;
// later polls retry.
func Collect() map[string]system.WiFi {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return collect(ctx)
}
// Signals reduces a snapshot to the RSSI values stored in stats history.
// Interfaces without an available reading are omitted.
func Signals(snapshot map[string]system.WiFi) map[string]int8 {
var signals map[string]int8
for id, reading := range snapshot {
if reading.Signal == nil {
continue
}
if signals == nil {
signals = make(map[string]int8, len(snapshot))
}
signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8))
}
return signals
}

49
agent/wifi/wifi_darwin.go Normal file
View File

@@ -0,0 +1,49 @@
//go:build darwin
package wifi
import (
"context"
"encoding/json"
"os"
"os/exec"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
const coreWLANScript = `ObjC.import('CoreWLAN');
var result = {};
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
if (interfaces) {
for (var i = 0; i < interfaces.count; i++) {
var iface = interfaces.objectAtIndex(i);
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
var name = ObjC.unwrap(iface.interfaceName);
if (!name) continue;
var reading = {};
var ssid = ObjC.unwrap(iface.ssid);
if (ssid) reading.s = ssid;
var signal = Number(iface.rssiValue);
if (signal >= -150 && signal < 0) reading.r = signal;
result[name] = reading;
}
}
JSON.stringify(result);`
func collect(ctx context.Context) map[string]system.WiFi {
cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.WaitDelay = 100 * time.Millisecond
output, err := cmd.Output()
if err != nil {
return nil
}
var result map[string]system.WiFi
if json.Unmarshal(output, &result) != nil {
return nil
}
return result
}

134
agent/wifi/wifi_linux.go Normal file
View File

@@ -0,0 +1,134 @@
//go:build linux
package wifi
import (
"context"
"errors"
"net"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
native "github.com/mdlayher/wifi"
"golang.org/x/sys/unix"
)
type linuxClient interface {
Interfaces() ([]*native.Interface, error)
BSS(*native.Interface) (*native.BSS, error)
Station(*native.Interface, net.HardwareAddr) (*native.StationInfo, error)
SetDeadline(time.Time) error
Close() error
}
// nl80211Client adds a targeted GET_STATION request, as used by `iw link`.
// mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
// out-of-tree Realtek USB) answer with an empty list.
type nl80211Client struct {
*native.Client
conn *genetlink.Conn
family genetlink.Family
}
func newNL80211Client() (*nl80211Client, error) {
client, err := native.New()
if err != nil {
return nil, err
}
conn, err := genetlink.Dial(nil)
if err != nil {
client.Close()
return nil, err
}
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
if err != nil {
conn.Close()
client.Close()
return nil, err
}
return &nl80211Client{Client: client, conn: conn, family: family}, nil
}
func (c *nl80211Client) Station(ifi *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
ae := netlink.NewAttributeEncoder()
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifi.Index))
ae.Bytes(unix.NL80211_ATTR_MAC, mac)
data, err := ae.Encode()
if err != nil {
return nil, err
}
msgs, err := c.conn.Execute(genetlink.Message{
Header: genetlink.Header{Command: unix.NL80211_CMD_GET_STATION, Version: c.family.Version},
Data: data,
}, c.family.ID, netlink.Request)
if err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, errors.New("no station info")
}
return native.ParseStationInfo(msgs[0].Data)
}
func (c *nl80211Client) SetDeadline(t time.Time) error {
return errors.Join(c.Client.SetDeadline(t), c.conn.SetDeadline(t))
}
func (c *nl80211Client) Close() error {
return errors.Join(c.conn.Close(), c.Client.Close())
}
func collect(ctx context.Context) map[string]system.WiFi {
client, err := newNL80211Client()
if err != nil {
return nil
}
defer client.Close()
return collectLinux(ctx, client)
}
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
result := make(map[string]system.WiFi)
if ctx.Err() != nil {
return result
}
if deadline, ok := ctx.Deadline(); ok {
if client.SetDeadline(deadline) != nil {
return result
}
}
interfaces, err := client.Interfaces()
if err != nil {
return result
}
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
continue
}
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
// Only the explicit associated status proves a current connection.
bss, err := client.BSS(iface)
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
continue
}
reading := system.WiFi{SSID: validSSID(bss.SSID)}
// Station statistics may require permissions unavailable in default
// containers. Keep association even when RSSI cannot be read. Do not
// substitute cached scan signal, which may be arbitrarily old.
if len(bss.BSSID) > 0 {
if station, err := client.Station(iface, bss.BSSID); err == nil && station != nil {
signal := float64(station.Signal)
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
}
result[iface.Name] = reading
}
return result
}

View File

@@ -0,0 +1,142 @@
//go:build linux
package wifi
import (
"bytes"
"context"
"errors"
"net"
"testing"
"time"
native "github.com/mdlayher/wifi"
)
type fakeLinuxClient struct {
interfaces []*native.Interface
bss map[string]*native.BSS
stations map[string][]*native.StationInfo
interfacesErr, bssErr, stationErr, deadlineErr error
deadline time.Time
stationCalls int
}
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
return f.interfaces, f.interfacesErr
}
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
return f.bss[i.Name], f.bssErr
}
func (f *fakeLinuxClient) Station(i *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
f.stationCalls++
if f.stationErr != nil {
return nil, f.stationErr
}
for _, station := range f.stations[i.Name] {
if bytes.Equal(station.HardwareAddr, mac) {
return station, nil
}
}
return nil, errors.New("no such station")
}
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
func (f *fakeLinuxClient) Close() error { return nil }
func connectedClient() *fakeLinuxClient {
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
return &fakeLinuxClient{
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
}
}
func TestLinuxSnapshots(t *testing.T) {
for _, tc := range []struct {
name string
modify func(*fakeLinuxClient)
want int
wantSignal bool
}{
{"connected", func(f *fakeLinuxClient) {}, 1, true},
{"multiple", func(f *fakeLinuxClient) {
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
f.bss["wlan1"] = f.bss["wlan0"]
}, 2, true},
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
{"station permission denied", func(f *fakeLinuxClient) {
f.stationErr = errors.New("permission denied")
f.bss["wlan0"].Signal = -4200
}, 1, false},
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := connectedClient()
tc.modify(f)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got := collectLinux(ctx, f)
if len(got) != tc.want {
t.Fatalf("got %#v", got)
}
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
t.Fatalf("signal: %#v", got["wlan0"])
}
if tc.wantSignal && *got["wlan0"].Signal != -52 {
t.Fatal(got)
}
if tc.want == 0 && f.stationCalls != 0 {
t.Fatal("queried station without association")
}
deadline, _ := ctx.Deadline()
if f.deadline != deadline {
t.Fatal("deadline not shared")
}
})
}
}
func TestReconnect(t *testing.T) {
f := connectedClient()
if len(collectLinux(context.Background(), f)) != 1 {
t.Fatal("initial")
}
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
if len(collectLinux(context.Background(), f)) != 0 {
t.Fatal("stale association")
}
f.bss["wlan0"].Status = native.BSSStatusAssociated
f.bss["wlan0"].SSID = "new"
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
t.Fatal("stale SSID")
}
}
func TestLinuxInvalidSSID(t *testing.T) {
f := connectedClient()
f.bss["wlan0"].SSID = "raw\xff"
got := collectLinux(context.Background(), f)
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
t.Fatal(got)
}
}
func TestLinuxCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
f := connectedClient()
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
t.Fatal("ignored cancellation")
}
}

57
agent/wifi/wifi_test.go Normal file
View File

@@ -0,0 +1,57 @@
package wifi
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestSSIDWireSafety(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
} {
t.Run(tc.input, func(t *testing.T) {
ssid := validSSID(tc.input)
if ssid != tc.want {
t.Fatalf("got %q, want %q", ssid, tc.want)
}
signal := -50.0
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
wire, err := cbor.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded map[string]system.WiFi
if err := cbor.Unmarshal(wire, &decoded); err != nil {
t.Fatal(err)
}
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
t.Fatal(decoded)
}
})
}
}
func TestSignals(t *testing.T) {
strong, weak, rounded := -40.0, -200.0, -52.6
got := Signals(map[string]system.WiFi{
"wlan0": {SSID: "home", Signal: &strong},
"wlan1": {Signal: &weak},
"wlan2": {Signal: &rounded},
"wlan3": {SSID: "no rssi"},
})
want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for id, signal := range want {
if got[id] != signal {
t.Fatalf("got %v, want %v", got, want)
}
}
if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil {
t.Fatal("expected nil without available readings")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !linux && !windows && !darwin
package wifi
import (
"context"
"github.com/henrygd/beszel/internal/entities/system"
)
func collect(context.Context) map[string]system.WiFi { return nil }

View File

@@ -0,0 +1,99 @@
//go:build windows
package wifi
import (
"context"
"unsafe"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/sys/windows"
)
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
var wlanOpen = wlan.NewProc("WlanOpenHandle")
var wlanClose = wlan.NewProc("WlanCloseHandle")
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
var wlanQuery = wlan.NewProc("WlanQueryInterface")
var wlanFree = wlan.NewProc("WlanFreeMemory")
type wlanInterface struct {
GUID windows.GUID
Description [256]uint16
State uint32
}
type wlanConnection struct {
State uint32
Mode uint32
Profile [256]uint16
SSIDLength uint32
SSID [32]byte
// Only the prefix through DOT11_SSID is read.
}
func collect(ctx context.Context) map[string]system.WiFi {
result := make(map[string]system.WiFi)
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
if proc.Find() != nil {
return result
}
}
var handle windows.Handle
var version uint32
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
return result
}
defer wlanClose.Call(uintptr(handle), 0)
var list unsafe.Pointer
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
return result
}
defer wlanFree.Call(uintptr(list))
count := *(*uint32)(list)
if count > 1024 {
return result
}
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface.State != 1 {
continue
} // wlan_interface_state_connected
reading := system.WiFi{}
// SSID access may be denied by location privacy policy. Association comes
// from the interface state, so missing SSID does not suppress valid RSSI.
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
connection := (*wlanConnection)(data)
if connection.State == 1 && connection.SSIDLength <= 32 {
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
}
}
wlanFree.Call(uintptr(data))
}
// Native RSSI LONG, not the quality percentage in association attributes.
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
if size >= 4 {
signal := float64(*(*int32)(data))
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
wlanFree.Call(uintptr(data))
}
result[iface.GUID.String()] = reading
}
return result
}
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
var data unsafe.Pointer
var size uint32
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
return nil, 0
}
return data, size
}

5
go.mod
View File

@@ -10,6 +10,9 @@ require (
github.com/fxamacker/cbor/v2 v2.9.4
github.com/gliderlabs/ssh v0.3.8
github.com/lxzan/gws v1.10.2
github.com/mdlayher/genetlink v1.4.0
github.com/mdlayher/netlink v1.11.2
github.com/mdlayher/wifi v0.8.0
github.com/nicholas-fedor/shoutrrr v0.21.0
github.com/opencontainers/go-digest v1.0.0
github.com/pocketbase/dbx v1.12.0
@@ -43,6 +46,7 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -50,6 +54,7 @@ require (
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mdlayher/socket v0.6.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect

8
go.sum
View File

@@ -83,6 +83,14 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.6.0 h1:ScZPaAGyO1icQnbFrhPM8mnXyMu9qukC1K4ZoM2IQKU=
github.com/mdlayher/socket v0.6.0/go.mod h1:q7vozUAnxSqnjHc12Fik5yUKIzfZ8ITCfMkhOtE9z18=
github.com/mdlayher/wifi v0.8.0 h1:qi73hVANXCYJEsT6t147dMILsx9V6UBNipZw0mPYdu0=
github.com/mdlayher/wifi v0.8.0/go.mod h1:QHQ211ZKtZKSKssCznixGUOqBcoyBQAuQWSAOnanY4A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=

View File

@@ -1,9 +1,11 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"runtime"
"strings"
"github.com/henrygd/beszel"
@@ -14,6 +16,12 @@ import (
"golang.org/x/crypto/ssh"
)
type noKeyProvidedError struct{}
func (noKeyProvidedError) Error() string {
return "no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage"
}
// cli options
type cmdOptions struct {
key string // key is the public key(s) for SSH authentication.
@@ -124,7 +132,7 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
// Try key file
keyFile, ok := utils.GetEnv("KEY_FILE")
if !ok {
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
return nil, noKeyProvidedError{}
}
pubKey, err := os.ReadFile(keyFile)
@@ -138,6 +146,14 @@ func (opts *cmdOptions) getAddress() string {
return agent.GetAddress(opts.listen)
}
func isBenignStartupError(err error, goos string) bool {
if goos != "windows" {
return false
}
var noKeyErr noKeyProvidedError
return errors.As(err, &noKeyErr)
}
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
func handleFingerprint() {
subCmd := ""
@@ -182,6 +198,12 @@ func main() {
var err error
serverConfig.Keys, err = opts.loadPublicKeys()
if err != nil {
if isBenignStartupError(err, runtime.GOOS) {
// WinGet launches the executable without configuration during validation.
// Exit successfully in that case while retaining the error on other platforms.
log.Print("Failed to load public keys:", err)
return
}
log.Fatal("Failed to load public keys:", err)
}

View File

@@ -2,6 +2,7 @@ package main
import (
"crypto/ed25519"
"errors"
"os"
"path/filepath"
"testing"
@@ -187,6 +188,26 @@ func TestLoadPublicKeys(t *testing.T) {
}
}
func TestIsBenignStartupError(t *testing.T) {
tests := []struct {
name string
err error
goos string
want bool
}{
{name: "missing key on windows", err: noKeyProvidedError{}, goos: "windows", want: true},
{name: "wrapped missing key on windows", err: errors.Join(errors.New("startup failed"), noKeyProvidedError{}), goos: "windows", want: true},
{name: "missing key on linux", err: noKeyProvidedError{}, goos: "linux", want: false},
{name: "different error on windows", err: errors.New("invalid key"), goos: "windows", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isBenignStartupError(tt.err, tt.goos))
})
}
}
func TestGetNetwork(t *testing.T) {
tests := []struct {
name string

View File

@@ -70,7 +70,9 @@ RUN set -eux; \
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
# zfsutils-linux is distributed in Debian's contrib component.
RUN sed -i 's/Components: main/Components: main contrib/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -25,6 +25,16 @@ type Config struct {
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
Port uint16 `cbor:"3,keyasint,omitempty"`
Interval uint16 `cbor:"4,keyasint"` // seconds
// Server is the DNS server to query (host or host:port, default port 53).
// Only used when Protocol is "dns"; empty means use the system resolver.
Server string `cbor:"5,keyasint,omitempty"`
}
// CertInfo holds details of the leaf TLS certificate presented by a target.
type CertInfo struct {
// Expires is the certificate's NotAfter Unix timestamp in milliseconds.
Expires int64 `cbor:"0,keyasint" json:"expires"`
Issuer string `cbor:"1,keyasint,omitempty" json:"issuer,omitempty"`
}
// SyncRequest defines an incremental or full monitor sync request sent to the agent.
@@ -76,6 +86,8 @@ type Result struct {
TotalCount int64 `cbor:"10,keyasint"`
SuccessCount int64 `cbor:"11,keyasint"`
ResponseSum int64 `cbor:"12,keyasint"`
// Cert is set for HTTPS targets when a certificate check has new info the hub has not stored yet.
Cert *CertInfo `cbor:"13,keyasint,omitempty"`
}
// Stats holds response times in microseconds and packet loss percentage (0-100).

View File

@@ -11,6 +11,14 @@ import (
"github.com/henrygd/beszel/internal/entities/systemd"
)
// WiFi describes a currently connected station interface. Keys in WiFi maps are
// OS interface identities, not SSIDs. Signal is native dBm only; nil means the
// OS confirmed association but could not supply RSSI (never convert quality %).
type WiFi struct {
SSID string `json:"s,omitempty" cbor:"0,keyasint,omitempty"`
Signal *float64 `json:"r,omitempty" cbor:"1,keyasint,omitempty"`
}
type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
@@ -55,6 +63,7 @@ type Stats struct {
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
ZfsPools map[string]*ZfsPool `json:"z,omitempty" cbor:"39,keyasint,omitempty"` // ZFS pool metrics, keyed by pool name
DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
WiFi map[string]int8 `json:"wf,omitempty" cbor:"40,keyasint,omitempty"` // RSSI dBm keyed by interface; unavailable readings omitted
}
@@ -110,7 +119,6 @@ type GPUData struct {
}
type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
Name string `json:"-"`
@@ -184,6 +192,8 @@ type Info struct {
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name)
PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown)
WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces
}
// Data that does not change during process lifetime and is not needed in All Systems table

View File

@@ -0,0 +1,40 @@
package system
import (
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
)
func TestWiFiWireSnapshot(t *testing.T) {
signal := -55.0
for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} {
original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: make(map[string]int8, len(wifi))}}
for id := range wifi {
original.Stats.WiFi[id] = -55
}
encoded, err := cbor.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded CombinedData
if err = cbor.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.Info.WiFi) != len(wifi) || len(decoded.Stats.WiFi) != len(wifi) {
t.Fatal(decoded)
}
encoded, err = json.Marshal(decoded.Info)
if err != nil {
t.Fatal(err)
}
var info map[string]any
if err = json.Unmarshal(encoded, &info); err != nil {
t.Fatal(err)
}
if _, ok := info["wf"]; ok != (len(wifi) > 0) {
t.Fatalf("wf present = %v for snapshot %v", ok, wifi)
}
}
}

View File

@@ -17,6 +17,10 @@ func generateMonitorID(systemId string, config monitor.Config) string {
if config.Protocol == "tcp" {
args = append(args, strconv.FormatUint(uint64(config.Port), 10))
}
// only use server for DNS monitors, so the same target queried via different servers gets distinct monitors
if config.Protocol == "dns" {
args = append(args, config.Server)
}
return systems.MakeStableHashId(args...)
}
@@ -53,10 +57,15 @@ func bindNetworkMonitorsEvents(hub *Hub) {
// record with the new ID and delete the old one. Otherwise, just update the existing monitor on the agent.
hub.OnRecordUpdateRequest("network_monitors").BindFunc(func(e *core.RecordRequestEvent) error {
systemID := e.Record.GetString("system")
protocol := e.Record.GetString("protocol")
// only tcp uses port - set other protocols port to zero
if e.Record.GetString("protocol") != "tcp" {
if protocol != "tcp" {
e.Record.Set("port", 0)
}
// only dns uses server - clear it for other protocols
if protocol != "dns" {
e.Record.Set("server", "")
}
ID := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record))
if ID != e.Record.Id {
newRecord := copyMonitorToNewRecord(e.Record, ID)
@@ -103,6 +112,7 @@ func monitorConfigFromRecord(record *core.Record) *monitor.Config {
Protocol: record.GetString("protocol"),
Port: uint16(record.GetInt("port")),
Interval: uint16(record.GetInt("interval")),
Server: record.GetString("server"),
}
}
@@ -114,6 +124,9 @@ func setMonitorResultFields(record *core.Record, result monitor.Result) {
record.Set("resMin1h", result.MinResponse1h)
record.Set("resMax1h", result.MaxResponse1h)
record.Set("loss1h", result.PacketLoss1h)
if result.Cert != nil {
record.Set("certInfo", result.Cert)
}
record.Set("updated", nowString)
}
@@ -124,7 +137,7 @@ func copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record {
collection := oldRecord.Collection()
newRecord := core.NewRecord(collection)
newRecord.Id = newID
fields := []string{"system", "target", "protocol", "port", "interval", "enabled"}
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled"}
for _, field := range fields {
newRecord.Set(field, oldRecord.Get(field))
}

View File

@@ -174,6 +174,39 @@ func TestGenerateMonitorID(t *testing.T) {
},
expected: "84167969",
},
{
name: "DNS monitor on example.com with server 1.1.1.1",
systemID: "sys999",
config: monitor.Config{
Protocol: "dns",
Target: "example.com",
Server: "1.1.1.1",
Interval: 30,
},
expected: "2175898b",
},
{
name: "DNS monitor on example.com with different server",
systemID: "sys999",
config: monitor.Config{
Protocol: "dns",
Target: "example.com",
Server: "8.8.8.8",
Interval: 30,
},
expected: "ebcd8b33",
},
{
name: "DNS monitor on example.com with no server (system resolver)",
systemID: "sys999",
config: monitor.Config{
Protocol: "dns",
Target: "example.com",
Server: "",
Interval: 30,
},
expected: "19476a7",
},
}
for _, tt := range tests {
@@ -199,6 +232,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
"target": "https://example.com",
"protocol": "http",
"port": 443,
"server": "1.1.1.1",
"interval": 60,
"enabled": true,
"res": 1200,
@@ -206,6 +240,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
"resMin1h": 900,
"resMax1h": 1600,
"loss1h": 5,
"certInfo": map[string]any{"expires": 1800000000000},
"updated": "2026-04-29 12:00:00.000Z",
})
@@ -215,7 +250,9 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
assert.Equal(t, "https://example.com", newRecord.GetString("target"))
assert.Equal(t, "http", newRecord.GetString("protocol"))
assert.Equal(t, 443, newRecord.GetInt("port"))
assert.Equal(t, "1.1.1.1", newRecord.GetString("server"))
assert.True(t, newRecord.GetBool("enabled"))
assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo"))
assert.Zero(t, newRecord.GetFloat("res"))
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
assert.Zero(t, newRecord.GetFloat("resMin1h"))

View File

@@ -222,3 +222,49 @@ func TestNetworkMonitorAlertsAfterCommit(t *testing.T) {
})
}
}
func TestNetworkMonitorCertPersistence(t *testing.T) {
for _, realtime := range []bool{false, true} {
name := "sql"
if realtime {
name = "realtime"
}
t.Run(name, func(t *testing.T) {
sys, app := newTestSystemWithHub(t)
if realtime {
client := subscriptions.NewDefaultClient()
client.Subscribe("network_monitors/*")
app.SubscriptionsBroker().Register(client)
t.Cleanup(func() { app.SubscriptionsBroker().Unregister(client.Id()) })
}
col, err := app.FindCachedCollectionByNameOrId("network_monitors")
require.NoError(t, err)
record := core.NewRecord(col)
record.Id = "monitor1"
record.Set("system", sys.Id)
require.NoError(t, app.SaveNoValidate(record))
storedCert := func() monitor.CertInfo {
t.Helper()
record, err := app.FindRecordById("network_monitors", "monitor1")
require.NoError(t, err)
var cert monitor.CertInfo
require.NoError(t, record.UnmarshalJSONField("certInfo", &cert))
return cert
}
cert := &monitor.CertInfo{Expires: 1_800_000_000_000, Issuer: "Test CA"}
_, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{
"monitor1": {LastProbeAt: 1000, Cert: cert},
}})
require.NoError(t, err)
assert.Equal(t, *cert, storedCert())
// Results without cert info keep the stored certificate.
_, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{
"monitor1": {LastProbeAt: 2000},
}})
require.NoError(t, err)
assert.Equal(t, *cert, storedCert())
})
}
}

View File

@@ -279,6 +279,10 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
// sync display name with hostname if enabled (details are fetched once per agent connection)
if syncNames, _ := utils.GetEnv("SYNC_SYSTEM_NAMES"); syncNames == "true" && data.Details.Hostname != "" {
systemRecord.Set("name", data.Details.Hostname)
}
}
if data.Monitors != nil {
@@ -433,6 +437,8 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map
for i, f := range monitorFields {
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
}
// Results omit certInfo unless it changed, so keep the stored value.
setClauses = append(setClauses, "certInfo=COALESCE({:certInfo}, certInfo)")
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", monitorCollectionName, strings.Join(setClauses, ", "))
updateQuery = db.NewQuery(queryString)
}
@@ -453,12 +459,24 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map
var record *core.Record
record, err = app.FindRecordById(monitorCollectionName, id)
if err == nil {
if result.Cert != nil {
monitorData["certInfo"] = result.Cert
}
record.Load(monitorData)
err = app.SaveNoValidate(record)
}
default:
monitorData["certInfo"] = nil
if result.Cert != nil {
var cert []byte
if cert, err = json.Marshal(result.Cert); err == nil {
monitorData["certInfo"] = string(cert)
}
}
if err == nil {
_, err = updateQuery.Bind(dbx.Params(monitorData)).Execute()
}
}
if err != nil {
app.Logger().Warn("Failed to update monitor", "system", systemId, "monitor", id, "err", err)
}
@@ -697,6 +715,11 @@ func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*syste
sys.syncPendingNetworkMonitors()
return wsData, nil
}
// A slow collection doesn't mean the connection is broken. Closing it
// would force the agent into a reconnect loop, so only report the error.
if errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
// close the WebSocket connection if error and try SSH
sys.closeWebSocketConnection()
}
@@ -709,12 +732,19 @@ func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*syste
return sshData, nil
}
// wsDataRequestTimeout bounds how long to wait for stats over WebSocket. Agent
// collection can legitimately take several seconds (e.g. a slow `zpool list`),
// so this must be well above the request manager's 5s default.
var wsDataRequestTimeout = 30 * time.Second
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
ctx, cancel := context.WithTimeout(context.Background(), wsDataRequestTimeout)
defer cancel()
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
err := wsTransport.Request(ctx, common.GetData, options, sys.data)
if err != nil {
return nil, err
}

View File

@@ -190,7 +190,9 @@ func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
// It clears system info when the status is changed to paused.
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
var prevInfo system.Info
e.Record.UnmarshalJSONField("info", &prevInfo)
e.Record.Set("info", system.Info{AgentVersion: prevInfo.AgentVersion})
}
return e.Next()
}
@@ -217,11 +219,15 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
// Pause monitoring but keep system in manager for potential resume
system.closeSSHConnection()
}
_ = deactivateAlerts(e.App, e.Record.Id)
_ = deactivateAlerts(e.App, e.Record.Id, false)
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
sm.hub.CancelPendingContainerAlerts(e.Record.Id)
return e.Next()
case pending:
// Keep an active status alert until connectivity is confirmed. This lets
// pending -> up resolve it and send the recovery notification after a
// system address or other connection setting is changed.
_ = deactivateAlerts(e.App, e.Record.Id, true)
// Resume monitoring, preferring existing WebSocket connection
if ok && system.WsConn != nil {
go system.update()
@@ -231,7 +237,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record, nil); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
case down:
// Docker state is unknown while the system is unreachable. Do not let a
@@ -254,8 +259,9 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
}
}
// Trigger status change alerts for up/down transitions
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
// A connection-setting update moves a down system through pending before it
// comes up, so recover active status alerts on any non-up -> up transition.
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus != up) {
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
@@ -413,10 +419,11 @@ func (sm *SystemManager) createSSHClientConfig() error {
return nil
}
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
// This is called when a system is paused or goes offline to prevent continued alerts.
// deactivateAlerts finds triggered alerts for a system and sets them to inactive.
// Status alerts can be preserved while connection changes are pending so that a
// confirmed recovery still produces an "up" notification.
// Monitor incidents remain open: a missing observation does not establish recovery.
func deactivateAlerts(app core.App, systemID string) error {
func deactivateAlerts(app core.App, systemID string, preserveStatusAlert bool) error {
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
@@ -426,6 +433,9 @@ func deactivateAlerts(app core.App, systemID string) error {
}
for _, alert := range alerts {
if preserveStatusAlert && alert.GetString("name") == "Status" {
continue
}
alert.Set("triggered", false)
if err := app.SaveNoValidate(alert); err != nil {
return err

View File

@@ -0,0 +1,34 @@
//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 TestCreateRecordsSyncSystemNames(t *testing.T) {
for _, tc := range []struct {
name string
env string
hostname string
expected string
}{
{"disabled", "", "new-host", "test-system"},
{"enabled", "true", "new-host", "new-host"},
{"enabled with empty hostname", "true", "", "test-system"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SYNC_SYSTEM_NAMES", tc.env)
sys, app := newTestSystemWithHub(t)
_, err := sys.createRecords(&system.CombinedData{Details: &system.Details{Hostname: tc.hostname}})
require.NoError(t, err)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
assert.Equal(t, tc.expected, record.GetString("name"))
})
}
}

View File

@@ -0,0 +1,28 @@
//go:build testing
package systems
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/require"
)
func TestCreateRecordsWiFiDisconnectReconnect(t *testing.T) {
sys, app := newTestSystemWithHub(t)
signal := -50.0
for _, snapshot := range []map[string]system.WiFi{
{"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {SSID: "other"}},
{}, nil,
{"wlan0": {SSID: "new", Signal: &signal}},
} {
_, err := sys.createRecords(&system.CombinedData{Info: system.Info{WiFi: snapshot}})
require.NoError(t, err)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
var info system.Info
require.NoError(t, record.UnmarshalJSONField("info", &info))
require.Len(t, info.WiFi, len(snapshot), "current info must replace previous connection state")
}
}

View File

@@ -18,6 +18,38 @@ import (
"github.com/stretchr/testify/require"
)
func TestPauseSystemPreservesAgentVersion(t *testing.T) {
hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup()
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "pause-info-test",
"host": "localhost",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
record.Set("info", system.Info{
AgentVersion: "0.20.0",
Cpu: 42.5,
MemPct: 60,
Uptime: 3600,
Services: []uint16{3, 1},
})
require.NoError(t, hub.Save(record))
record.Set("status", "paused")
require.NoError(t, hub.Save(record))
pausedRecord, err := hub.FindRecordById("systems", record.Id)
require.NoError(t, err)
assert.Equal(t, "paused", pausedRecord.GetString("status"))
var info system.Info
require.NoError(t, pausedRecord.UnmarshalJSONField("info", &info))
assert.Equal(t, system.Info{AgentVersion: "0.20.0"}, info)
}
func TestSystemManagerNew(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
if err != nil {
@@ -133,6 +165,55 @@ func TestSystemManagerNew(t *testing.T) {
})
}
func TestStatusAlertRecoveryAfterPendingTransition(t *testing.T) {
hub, user := tests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", map[string]any{
"emails": []string{"test@example.com"},
"webhooks": []string{},
})
require.NoError(t, hub.Save(userSettings))
record, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "changed-address",
"host": "192.0.2.1",
"port": "33914",
"users": []string{user.Id},
})
require.NoError(t, err)
record.Set("status", "down")
require.NoError(t, hub.Save(record))
alert, err := tests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": record.Id,
"user": user.Id,
"min": 1,
"triggered": true,
})
require.NoError(t, err)
initialEmailCount := hub.TestMailer.TotalSend()
// The edit dialog temporarily moves the system through pending. The active
// status alert must remain active until the new connection is confirmed.
record.Set("host", "192.0.2.2")
record.Set("status", "pending")
require.NoError(t, hub.Save(record))
alert, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.True(t, alert.GetBool("triggered"), "pending connection update should preserve the active status alert")
record.Set("status", "up")
require.NoError(t, hub.Save(record))
alert, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, alert.GetBool("triggered"), "pending -> up should resolve the active status alert")
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "recovery should send an up notification")
}
func testOld(t *testing.T, hub *tests.TestHub) {
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
require.NoError(t, err)

View File

@@ -0,0 +1,82 @@
//go:build testing
package systems
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
esystem "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/lxzan/gws"
"github.com/stretchr/testify/require"
)
// slowDataClient answers GetData only after release is closed, simulating an
// agent whose collection outlasts the hub's request timeout.
type slowDataClient struct {
gws.BuiltinEventHandler
release chan struct{}
}
func (c *slowDataClient) OnMessage(conn *gws.Conn, message *gws.Message) {
defer message.Close()
var req common.HubRequest[cbor.RawMessage]
if err := cbor.Unmarshal(message.Bytes(), &req); err != nil || req.Action != common.GetData {
return
}
<-c.release
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, SystemData: &esystem.CombinedData{}})
_ = conn.WriteMessage(gws.OpcodeBinary, response)
}
func TestFetchDataTimeoutKeepsWebSocketOpen(t *testing.T) {
originalTimeout := wsDataRequestTimeout
wsDataRequestTimeout = 50 * time.Millisecond
t.Cleanup(func() { wsDataRequestTimeout = originalTimeout })
connections := make(chan *ws.WsConn, 1)
upgrader := gws.NewUpgrader(&monitorSyncServer{}, nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r)
if err != nil {
t.Error(err)
return
}
wsConn := ws.NewWsConnection(conn, semver.MustParse("0.20.0"))
conn.Session().Store("wsConn", wsConn)
connections <- wsConn
conn.ReadLoop()
}))
t.Cleanup(server.Close)
client := &slowDataClient{release: make(chan struct{})}
conn, _, err := gws.NewClient(client, &gws.ClientOption{Addr: "ws" + strings.TrimPrefix(server.URL, "http")})
require.NoError(t, err)
t.Cleanup(func() { _ = conn.NetConn().Close() })
go conn.ReadLoop()
var sys *System
select {
case wsConn := <-connections:
sys = &System{WsConn: wsConn}
case <-time.After(3 * time.Second):
t.Fatal("websocket connection was not established")
}
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
require.ErrorIs(t, err, context.DeadlineExceeded)
require.True(t, sys.WsConn.IsConnected(), "a slow collection must not close the connection")
// The late response is discarded and the next request still succeeds.
close(client.release)
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
require.NoError(t, err)
}

View File

@@ -35,6 +35,14 @@ func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction,
}
// Try generic Data field first (0.19+)
if len(resp.Data) > 0 {
// Wi-Fi maps are complete snapshots. CBOR otherwise merges entries into
// reused destinations, retaining disconnected interfaces and old RSSI.
if action == common.GetData {
if data, ok := dest.(*system.CombinedData); ok {
data.Info.WiFi = nil
data.Stats.WiFi = nil
}
}
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
}

View File

@@ -0,0 +1,43 @@
package transport
import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/require"
"testing"
)
func TestWiFiSequentialResponseSnapshots(t *testing.T) {
signal := -50.0
var decoded system.CombinedData
for _, snapshot := range []map[string]system.WiFi{
{"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {Signal: &signal}},
{"wlan0": {SSID: "home"}}, {}, nil,
{"wlan1": {SSID: "new", Signal: &signal}},
} {
signals := make(map[string]int8)
for id, reading := range snapshot {
if reading.Signal != nil {
signals[id] = int8(*reading.Signal)
}
}
payload, err := cbor.Marshal(system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: signals}})
require.NoError(t, err)
require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded))
require.Len(t, decoded.Info.WiFi, len(snapshot))
require.Len(t, decoded.Stats.WiFi, len(signals))
for id, want := range snapshot {
require.Equal(t, want, decoded.Info.WiFi[id])
}
for id, want := range signals {
require.Equal(t, want, decoded.Stats.WiFi[id])
}
}
// An older generic-response agent may omit both fields entirely.
payload, err := cbor.Marshal(map[int]any{0: map[int]any{}, 1: map[int]any{}})
require.NoError(t, err)
require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded))
require.Empty(t, decoded.Info.WiFi)
require.Empty(t, decoded.Stats.WiFi)
}

View File

@@ -58,36 +58,38 @@ func Update(cmd *cobra.Command, _ []string) {
func restartService() {
// Check if we're running as a service by looking for systemd
if _, err := exec.LookPath("systemctl"); err == nil {
// Check if beszel service exists and is active
cmd := exec.Command("systemctl", "is-active", "beszel.service")
if err := cmd.Run(); err == nil {
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
if err := restartCmd.Run(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
} else {
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
// install-hub.sh names the unit beszel-hub.service. beszel.service is
// kept as a fallback for hand written units.
for _, unit := range []string{"beszel-hub.service", "beszel.service"} {
if err := exec.Command("systemctl", "is-active", unit).Run(); err != nil {
continue
}
reportRestart(exec.Command("systemctl", "restart", unit), "sudo systemctl restart "+unit)
return
}
}
// Check for OpenRC (Alpine Linux)
if _, err := exec.LookPath("rc-service"); err == nil {
cmd := exec.Command("rc-service", "beszel", "status")
if err := cmd.Run(); err == nil {
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
restartCmd := exec.Command("rc-service", "beszel", "restart")
if err := restartCmd.Run(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
} else {
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
for _, service := range []string{"beszel-hub", "beszel"} {
if err := exec.Command("rc-service", service, "status").Run(); err != nil {
continue
}
reportRestart(exec.Command("rc-service", service, "restart"), "sudo rc-service "+service+" restart")
return
}
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
}
// reportRestart runs the restart command and prints the result.
func reportRestart(cmd *exec.Cmd, manualCommand string) {
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
if err := cmd.Run(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: "+manualCommand)
} else {
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
}
}

View File

@@ -0,0 +1,24 @@
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("network_monitors")
if err != nil {
return err
}
collection.Fields.Add(&core.JSONField{Name: "certInfo"})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("network_monitors")
if err != nil {
return err
}
collection.Fields.RemoveByName("certInfo")
return app.Save(collection)
})
}

View File

@@ -0,0 +1,24 @@
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("network_monitors")
if err != nil {
return err
}
collection.Fields.Add(&core.TextField{Id: "nm_server", Name: "server", Max: 260})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("network_monitors")
if err != nil {
return err
}
collection.Fields.RemoveByName("server")
return app.Save(collection)
})
}

View File

@@ -267,6 +267,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
return sum
}
wifiSums := make(map[string]int)
wifiCounts := make(map[string]int)
// necessary because uint8 is not big enough for the sum
batterySum := 0
batteryCount := 0
@@ -285,6 +287,10 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// Accumulate totals
for i := range records {
stats := &records[i]
for id, signal := range stats.WiFi {
wifiSums[id] += int(signal)
wifiCounts[id]++
}
sum.Cpu += stats.Cpu
// accumulate cpu time breakdowns if present
@@ -614,6 +620,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
sum.CpuBreakdown = avg
}
// RSSI averages exclude records where the interface was absent.
if len(wifiSums) > 0 {
sum.WiFi = make(map[string]int8, len(wifiSums))
for id, total := range wifiSums {
sum.WiFi[id] = int8(math.Round(float64(total) / float64(wifiCounts[id])))
}
}
return sum
}

View File

@@ -0,0 +1,25 @@
package records
import (
"testing"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestWiFiAverageAvailableSamples(t *testing.T) {
input := []system.Stats{
{WiFi: map[string]int8{"wlan0": -40}},
{},
{WiFi: map[string]int8{"wlan0": -61, "wlan1": -80}},
}
result := AverageSystemStatsSlice(input)
if len(result.WiFi) != 2 || result.WiFi["wlan0"] != -51 || result.WiFi["wlan1"] != -80 {
t.Fatalf("%#v", result.WiFi)
}
if input[0].WiFi["wlan0"] != -40 {
t.Fatal("mutated input")
}
if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 {
t.Fatal("invented wifi")
}
}

View File

@@ -116,8 +116,6 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
}
}
const systemTranslation = t`System`
return (
<DialogContent
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
@@ -129,9 +127,9 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
<DialogHeader>
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
{system ? (
<Trans>Edit {{ foo: systemTranslation }}</Trans>
<Trans>Edit System</Trans>
) : (
<Trans>Add {{ foo: systemTranslation }}</Trans>
<Trans>Add System</Trans>
)}
</DialogTitle>
<TabsList className="grid w-full grid-cols-2">
@@ -268,9 +266,9 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
{/* Save */}
<Button>
{system ? (
<Trans>Save {{ foo: systemTranslation }}</Trans>
<Trans>Save System</Trans>
) : (
<Trans>Add {{ foo: systemTranslation }}</Trans>
<Trans>Add System</Trans>
)}
</Button>
</DialogFooter>

View File

@@ -1,4 +1,3 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router"
import {
@@ -48,8 +47,6 @@ export default function Navbar() {
const AdminLinks = AdminDropdownGroup()
const systemTranslation = t`System`
return (
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
<Suspense>
@@ -140,7 +137,7 @@ export default function Navbar() {
}}
>
<PlusIcon className="h-4 w-4 me-2.5" />
<Trans>Add {{ foo: systemTranslation }}</Trans>
<Trans>Add System</Trans>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
@@ -243,7 +240,7 @@ export default function Navbar() {
{!isReadOnlyUser() && (
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>Add {{ foo: systemTranslation }}</Trans>
<Trans>Add System</Trans>
</Button>
)}
</div>
@@ -261,7 +258,7 @@ function AdminDropdownGroup() {
return (
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/")} target="_blank">
<a href={prependBasePath("/_/#/collections?collection=users")} target="_blank">
<UsersIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Users</Trans>

View File

@@ -23,7 +23,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import { ChevronDownIcon, ListIcon, SearchIcon, ServerIcon } from "lucide-react"
import { ChevronDownIcon, ListIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react"
import { useToast } from "@/components/ui/use-toast"
import { $systems } from "@/lib/stores"
import { cn, supportsNetworkMonitors } from "@/lib/utils"
@@ -37,6 +37,7 @@ type MonitorValues = {
target: string
protocol: MonitorProtocol
port: number
server: string
interval: string
}
@@ -44,7 +45,7 @@ type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
interval: number
}
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval">
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval" | "server">
const defaultInterval = 30
@@ -59,6 +60,7 @@ const NormalizedMonitorValuesSchema = v.pipe(
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
protocol: MonitorProtocolSchema,
port: v.number(),
server: v.pipe(v.string(), v.trim()),
interval: MonitorIntervalSchema,
}),
v.transform((input): NormalizedMonitorValues => {
@@ -78,6 +80,8 @@ const NormalizedMonitorValuesSchema = v.pipe(
target: protocol === "http" ? httpTarget : input.target,
protocol,
port,
// Only DNS monitors use a custom server; clear it for other protocols.
server: protocol === "dns" ? input.server : "",
interval: input.interval,
}
}),
@@ -100,6 +104,7 @@ const BulkMonitorSchema = v.object({
protocol: v.optional(v.pipe(v.string(), v.trim())),
port: v.optional(v.pipe(v.string(), v.trim())),
interval: v.optional(v.pipe(v.string(), v.trim())),
server: v.optional(v.pipe(v.string(), v.trim())),
})
function normalizeHttpTarget(target: string, port = 0) {
@@ -152,18 +157,19 @@ function buildMonitorPayload(values: MonitorValues, enabled = true) {
return payload
}
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port">
function getMonitorIdentityKey({ system, target, protocol, port }: MonitorIdentity) {
return `${system}${target}${protocol}${port}`
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port" | "server">
function getMonitorIdentityKey({ system, target, protocol, port, server }: MonitorIdentity) {
return `${system}${target}${protocol}${port}${protocol === "dns" ? server : ""}`
}
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = ""] = line.split(",")
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", rawServer = ""] = line.split(",")
const parsed = v.safeParse(BulkMonitorSchema, {
target: rawTarget,
protocol: rawProtocol,
port: rawPort,
interval: rawInterval,
server: rawServer,
})
if (!parsed.success) {
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid monitor entry"}`)
@@ -176,6 +182,7 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
target: parsed.output.target,
protocol,
port: parsed.output.port ? Number(parsed.output.port) : 0,
server: parsed.output.server || "",
interval: parsed.output.interval || `${defaultInterval}`,
})
}
@@ -183,7 +190,8 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) {
const port = monitor.protocol !== "tcp" || monitor.port === 443 ? "" : `${monitor.port}`
const interval = monitor.interval === defaultInterval ? "" : `${monitor.interval}`
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval]).join(",")
const server = monitor.protocol !== "dns" ? "" : monitor.server
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval, server]).join(",")
}
function SystemMultiSelect({
@@ -353,6 +361,8 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
const bulkFormRef = useRef<HTMLFormElement>(null)
const { toast } = useToast()
const { t } = useLingui()
const systems = useStore($systems)
const hasEligibleSystems = systemId ? true : systems.some(supportsNetworkMonitors)
const resetBulkForm = () => {
setBulkInput("")
@@ -443,14 +453,24 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
return (
<>
<div className="flex gap-0 rounded-lg">
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
{/* <PlusIcon className="size-4 me-1" /> */}
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow" disabled={!hasEligibleSystems}>
<PlusIcon className="size-4 me-1" />
<span className="sm:hidden">
<Trans>Add</Trans>
</span>
<span className="hidden sm:inline">
<Trans>Add {{ foo: t`Monitor` }}</Trans>
</span>
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={`More actions`}>
<Button
variant="outline"
className="px-2 rounded-s-none border-s-0"
aria-label={`More actions`}
disabled={!hasEligibleSystems}
>
<ChevronDownIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
@@ -485,7 +505,9 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
<SheetTitle>
<Trans>Bulk Add {{ foo: t`Network Monitors` }}</Trans>
</SheetTitle>
<SheetDescription>target[,protocol[,port[,interval]]]</SheetDescription>
<SheetDescription>
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
</SheetDescription>
</SheetHeader>
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
<div className="flex-1 flex flex-col space-y-4 overflow-auto p-4">
@@ -518,10 +540,17 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
}
}}
className="font-mono grow text-sm bg-card"
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60"].join("\n")}
placeholder={[
"1.1.1.1",
"example.com,tcp",
"https://example.com,http,,60",
"example.com,dns,,,1.1.1.1",
].join("\n")}
required
/>
<p className="text-xs text-muted-foreground">target[,protocol[,port[,interval]]]</p>
<p className="text-xs text-muted-foreground">
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
</p>
</div>
</div>
<SheetFooter className="border-t">
@@ -575,6 +604,7 @@ function MonitorDialogContent({
const [protocol, setProtocol] = useState<MonitorProtocol>(monitor?.protocol ?? "icmp")
const [target, setTarget] = useState(monitor?.target ?? "")
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
const [server, setServer] = useState(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
const [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
const [loading, setLoading] = useState(false)
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
@@ -593,6 +623,7 @@ function MonitorDialogContent({
setProtocol(monitor?.protocol ?? "icmp")
setTarget(monitor?.target ?? "")
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
setServer(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
setSelectedSystemId(monitor?.system ?? "")
setSelectedSystemIds(new Set())
@@ -613,6 +644,7 @@ function MonitorDialogContent({
target,
protocol,
port: protocol === "tcp" ? Number(port) : 0,
server: protocol === "dns" ? server.trim() : "",
interval: monitorInterval,
},
monitor ? monitor.enabled : true
@@ -693,7 +725,7 @@ function MonitorDialogContent({
<Input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
placeholder={protocol === "http" ? "http://localhost:8090" : protocol === "dns" ? "example.com" : "1.1.1.1"}
required
/>
</div>
@@ -729,6 +761,21 @@ function MonitorDialogContent({
/>
</div>
)}
{protocol === "dns" && (
<div className="grid gap-2">
<Label>
<Trans>DNS Server</Trans>
</Label>
<Input
value={server}
onChange={(e) => setServer(e.target.value)}
placeholder="1.1.1.1"
/>
<p className="text-xs text-muted-foreground">
<Trans>Optional. Defaults to the agent's system resolver.</Trans>
</p>
</div>
)}
<div className="grid gap-2">
<Label>
<Trans>Interval (seconds)</Trans>

View File

@@ -16,6 +16,7 @@ import {
PlayCircleIcon,
CopyIcon,
CopyPlusIcon,
ShieldCheckIcon,
} from "lucide-react"
import { t } from "@lingui/core/macro"
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
@@ -29,17 +30,26 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Trans } from "@lingui/react/macro"
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
import { Plural, Trans } from "@lingui/react/macro"
import { $allSystemsById } from "@/lib/stores"
import type { ReadableAtom } from "nanostores"
import { useStore } from "@nanostores/react"
import { SystemStatus } from "@/lib/enums"
import { Checkbox } from "@/components/ui/checkbox"
import { useMemo } from "react"
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
import { Badge } from "../ui/badge"
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
import { pb } from "@/lib/api"
const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" }
declare module "@tanstack/react-table" {
interface ColumnMeta<TData, TValue> {
label?: string
}
}
const protocolColors: Record<string, string> = {
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
@@ -62,6 +72,7 @@ const isMuted = (record: NetworkMonitorRecord, systemRecord: SystemRecord | unde
export function getMonitorColumns(
longestTarget = "",
$longestSystemName: ReadableAtom<string>,
{
onEdit,
onDelete,
@@ -98,6 +109,7 @@ export function getMonitorColumns(
},
{
id: "system",
meta: { label: t`System` },
accessorFn: (record) => record.system,
sortingFn: (a, b) => {
const allSystems = $allSystemsById.get()
@@ -128,12 +140,13 @@ export function getMonitorColumns(
</div>
</div>
),
[status, name]
[status, name, longestSystemName]
)
},
},
{
id: "target",
meta: { label: t`Target` },
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
accessorFn: (record) => getMonitorTarget(record),
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
@@ -146,6 +159,8 @@ export function getMonitorColumns(
color = "bg-primary/40"
} else if (status === SystemStatus.Down || status === SystemStatus.Pending) {
color = "bg-yellow-500"
} else if (monitor.updated && !monitor.res) {
color = "bg-red-500"
}
return (
<div className="ms-1.5 max-w-64 flex gap-2 items-center tabular-nums">
@@ -162,6 +177,7 @@ export function getMonitorColumns(
},
{
id: "protocol",
meta: { label: t`Protocol` },
accessorFn: (record) => record.protocol,
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
cell: ({ getValue }) => {
@@ -171,6 +187,7 @@ export function getMonitorColumns(
},
{
id: "interval",
meta: { label: t`Interval` },
accessorFn: (record) => record.interval,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
@@ -178,6 +195,7 @@ export function getMonitorColumns(
},
{
id: "res",
meta: { label: t`Response` },
accessorFn: (record) => record.res,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
@@ -185,6 +203,7 @@ export function getMonitorColumns(
},
{
id: "res1h",
meta: { label: t`Avg 1h` },
accessorFn: (record) => record.resAvg1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
@@ -192,6 +211,7 @@ export function getMonitorColumns(
},
{
id: "max1h",
meta: { label: t`Max 1h` },
accessorFn: (record) => record.resMax1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
@@ -199,6 +219,7 @@ export function getMonitorColumns(
},
{
id: "min1h",
meta: { label: t`Min 1h` },
accessorFn: (record) => record.resMin1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
@@ -206,6 +227,7 @@ export function getMonitorColumns(
},
{
id: "loss",
meta: { label: t`Loss 1h` },
accessorFn: (record) => record.loss1h,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
@@ -232,8 +254,34 @@ export function getMonitorColumns(
)
},
},
{
id: "cert",
meta: { label: t`Certificate` },
accessorFn: (record) => record.certInfo?.expires,
header: ({ column }) => <HeaderButton column={column} name={t`Certificate`} Icon={ShieldCheckIcon} />,
cell: ({ row }) => {
const { certInfo, system } = row.original
const systemRecord = useStore($allSystemsById)[system]
if (!certInfo?.expires) {
return <span className="ms-1.5 text-muted-foreground">-</span>
}
const daysLeft = getCertDaysLeft(certInfo)
const color = isMuted(row.original, systemRecord)
? "bg-muted-foreground/50"
: certExpiryDotColors[getCertExpiryLevel(daysLeft)]
return (
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
<span className={cn("shrink-0 size-2 rounded-full", color)} />
{daysLeft < 0 ? <Trans>Expired</Trans> : <Plural value={daysLeft} one="# day" other="# days" />}
</span>
)
},
},
{
id: "updated",
meta: { label: t`Updated` },
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,

View File

@@ -1,4 +1,4 @@
import { getMonitorTarget } from "@/lib/network-monitor-utils"
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
@@ -26,26 +26,44 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { memo, useCallback, useMemo, useRef, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { subscribeKeys } from "nanostores"
import { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-columns"
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser } from "@/lib/api"
import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
import { pb } from "@/lib/api"
import { $allSystemsById, $direction, $userSettings } from "@/lib/stores"
import {
cn,
isVisuallyLonger,
matchesFilterGroups,
parseFilterGroups,
parseSemVer,
useBrowserStorage,
} from "@/lib/utils"
import type { ChartData, NetworkMonitorRecord } from "@/types"
import { SystemStatus } from "@/lib/enums"
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
import { ArrowLeftRightIcon, EthernetPortIcon, LoaderCircleIcon, ServerIcon, XIcon } from "lucide-react"
import {
ArrowDownIcon,
ArrowLeftRightIcon,
ArrowUpDownIcon,
ArrowUpIcon,
EthernetPortIcon,
EyeIcon,
GlobeIcon,
LandmarkIcon,
LoaderCircleIcon,
ServerIcon,
Settings2Icon,
ShieldCheckIcon,
XIcon,
} from "lucide-react"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts"
@@ -65,13 +83,19 @@ export default function NetworkMonitorsTableNew({
monitors: NetworkMonitorRecord[]
isLoading: boolean
}) {
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-np-target-${systemId ? 1 : 0}`,
[{ id: systemId ? "target" : "system", desc: false }],
sessionStorage
const sortSettingsKey = systemId ? "monitorSortModeSystem" : "monitorSortMode"
const sortStorageKey = `besz-sort-np-target-${systemId ? 1 : 0}`
const [sorting, setSorting] = useState<SortingState>(
() =>
$userSettings.get()[sortSettingsKey] ??
JSON.parse(sessionStorage.getItem(sortStorageKey) || "null") ?? [
{ id: systemId ? "target" : "system", desc: false },
]
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => $userSettings.get().monitorCols ?? JSON.parse(localStorage.getItem("besz-monitor-cols") || "{}")
)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [globalFilter, setGlobalFilter] = useState("")
const [deleteOpen, setDeleteOpen] = useState(false)
@@ -81,6 +105,49 @@ export default function NetworkMonitorsTableNew({
const { toast } = useToast()
const canManageMonitors = !isReadOnlyUser()
// Apply settings from server once they load (handles incognito / new devices)
const appliedSettings = useRef(new Set<string>())
useEffect(() => {
return subscribeKeys($userSettings, ["monitorCols", sortSettingsKey], (vals) => {
if (!appliedSettings.current.has("monitorCols") && vals.monitorCols !== undefined) {
appliedSettings.current.add("monitorCols")
setColumnVisibility(vals.monitorCols)
}
if (!appliedSettings.current.has(sortSettingsKey) && vals[sortSettingsKey] !== undefined) {
appliedSettings.current.add(sortSettingsKey)
setSorting(vals[sortSettingsKey] as SortingState)
}
})
}, [sortSettingsKey])
const handleColumnVisibilityChange = useCallback(
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
setColumnVisibility((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
localStorage.setItem("besz-monitor-cols", JSON.stringify(next))
$userSettings.setKey("monitorCols", next)
queueUserSettings({ monitorCols: next })
return next
})
},
[]
)
const handleSortingChange = useCallback(
(updater: SortingState | ((prev: SortingState) => SortingState)) => {
setSorting((prev) => {
const next = typeof updater === "function" ? updater(prev) : updater
sessionStorage.setItem(sortStorageKey, JSON.stringify(next))
$userSettings.setKey(sortSettingsKey, next)
queueUserSettings({ [sortSettingsKey]: next })
return next
})
},
[sortSettingsKey, sortStorageKey]
)
// recompute when measured widths are invalidated (e.g. web font finished loading)
const textMeasureVersion = useStore($textMeasureVersion)
const longestTarget = useMemo(() => {
let longestTarget = ""
for (const p of monitors) {
@@ -89,7 +156,27 @@ export default function NetworkMonitorsTableNew({
}
}
return longestTarget
}, [monitors])
}, [monitors, textMeasureVersion])
// longest name among systems that have monitors in this table (skipped for single-system view).
// Held in a store because memoized rows don't re-render when column definitions change.
const $longestSystemName = useMemo(() => atom(""), [])
useEffect(() => {
if (systemId) {
return
}
const systemIds = new Set(monitors.map((m) => m.system))
return $allSystemsById.subscribe((systems) => {
let longest = ""
for (const id of systemIds) {
const name = systems[id]?.name ?? ""
if (isVisuallyLonger(name, longest)) {
longest = name
}
}
$longestSystemName.set(longest)
})
}, [monitors, systemId, textMeasureVersion, $longestSystemName])
const runMonitorBatch = useCallback(
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
@@ -190,7 +277,7 @@ export default function NetworkMonitorsTableNew({
)
const columns = useMemo(() => {
let columns = getMonitorColumns(longestTarget, {
let columns = getMonitorColumns(longestTarget, $longestSystemName, {
onEdit: setEditingMonitor,
onDelete: handleDeleteRequest,
onSetEnabled: handleSetEnabled,
@@ -198,7 +285,7 @@ export default function NetworkMonitorsTableNew({
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
columns = canManageMonitors ? columns : columns.filter((col) => col.id !== "actions")
return columns
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget])
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget, $longestSystemName])
const table = useReactTable({
data: monitors,
@@ -207,9 +294,9 @@ export default function NetworkMonitorsTableNew({
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onSortingChange: handleSortingChange,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
onRowSelectionChange: setRowSelection,
defaultColumn: {
sortUndefined: "last",
@@ -236,11 +323,12 @@ export default function NetworkMonitorsTableNew({
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
const visibleColumnsKey = visibleColumns.map((column) => column.id).join(",")
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="grid md-lg:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Network Monitors</Trans>
@@ -249,14 +337,14 @@ export default function NetworkMonitorsTableNew({
<Trans>Response time monitoring from agents.</Trans>
</div>
</div>
<div className="md:ms-auto flex items-center gap-2">
<div className="md-lg:ms-auto flex items-center gap-2">
{monitors.length > 0 && (
<div className="relative">
<div className="relative grow">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-50"
className="ms-auto px-4 w-full max-w-full md-lg:w-50"
/>
{globalFilter && (
<Button
@@ -272,6 +360,74 @@ export default function NetworkMonitorsTableNew({
)}
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Settings2Icon className="me-1.5 size-4 opacity-80" />
<Trans>View</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
<div className="grid grid-cols-2 divide-y md:divide-s md:divide-y-0">
<div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<ArrowUpDownIcon className="size-4" />
<Trans>Sort By</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1 pb-1">
{table.getAllColumns().map((column) => {
if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span>
if (sorting[0]?.id === column.id) {
Icon = sorting[0]?.desc ? (
<ArrowUpIcon className="me-2 size-4" />
) : (
<ArrowDownIcon className="me-2 size-4" />
)
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
handleSortingChange([
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
])
}}
key={column.id}
>
{Icon}
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuItem>
)
})}
</div>
</div>
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.columnDef.meta?.label ?? column.id}
</DropdownMenuCheckboxItem>
))}
</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
{canManageMonitors ? (
<EditMonitorDialog
@@ -324,6 +480,7 @@ export default function NetworkMonitorsTableNew({
table={table}
rows={rows}
colLength={visibleColumns.length}
visibleColumnsKey={visibleColumnsKey}
rowSelection={rowSelection}
isLoading={isLoading}
/>
@@ -336,12 +493,14 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
table,
rows,
colLength,
visibleColumnsKey,
rowSelection,
isLoading,
}: {
table: TableType<NetworkMonitorRecord>
rows: Row<NetworkMonitorRecord>[]
colLength: number
visibleColumnsKey: string
rowSelection: RowSelectionState
isLoading: boolean
}) {
@@ -389,6 +548,7 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
virtualRow={virtualRow}
isSelected={row.getIsSelected()}
rowSelection={rowSelection}
visibleColumnsKey={visibleColumnsKey}
openSheet={openSheet}
/>
)
@@ -441,6 +601,9 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
virtualRow,
isSelected,
rowSelection: _rowSelection,
// Column visibility doesn't change the row object identity, so this prop exists only
// to force a re-render (and a fresh row.getVisibleCells() read) when columns are toggled.
visibleColumnsKey: _visibleColumnsKey,
openSheet,
}: {
row: Row<NetworkMonitorRecord>
@@ -448,12 +611,16 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
isSelected: boolean
// Menus depend on the entire selection, including changes to other rows.
rowSelection: RowSelectionState
visibleColumnsKey: string
openSheet: (monitor: NetworkMonitorRecord) => void
}) {
const system = useStore($allSystemsById)[row.original.system]
return (
<TableRow
data-state={isSelected && "selected"}
className="cursor-pointer transition-opacity"
className={cn("cursor-pointer transition-opacity", {
"opacity-50": system?.status === SystemStatus.Paused,
})}
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
@@ -488,6 +655,36 @@ function NetworkMonitorSheet({
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
}
const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" }
function CertExpiry({ cert }: { cert: MonitorCertInfo }) {
const daysLeft = getCertDaysLeft(cert)
const expires = formatShortDate(new Date(cert.expires).toISOString())
const level = getCertExpiryLevel(daysLeft)
return (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<ShieldCheckIcon className={cn("size-3.5 text-muted-foreground -me-1", certExpiryTextColors[level])} />
<span className={certExpiryTextColors[level]}>
{daysLeft < 0 ? (
<Trans>Certificate expired {expires}</Trans>
) : (
<Trans>
Certificate expires {expires}
</Trans>
)}
</span>
{cert.issuer && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<LandmarkIcon className="size-3.5 text-muted-foreground -me-0.5" />
<span>{cert.issuer}</span>
</>
)}
</>
)
}
function NetworkMonitorSheetContent({
open,
onOpenChange,
@@ -499,7 +696,7 @@ function NetworkMonitorSheetContent({
}) {
// Keep monitor exploration independent of the system charts' time range.
const [chartTimeStore] = useState(() => {
const defaultTime = $userSettings.get().chartTime
const defaultTime = getUserChartTime()
return atom(defaultTime === "1m" ? "1h" : defaultTime)
})
const chartTime = useStore(chartTimeStore)
@@ -535,7 +732,7 @@ function NetworkMonitorSheetContent({
{system?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground -me-0.5" />
{monitor.protocol.toUpperCase()}
{monitor.protocol === "tcp" && monitor.port > 0 && (
<>
@@ -544,6 +741,14 @@ function NetworkMonitorSheetContent({
<span>{monitor.port}</span>
</>
)}
{monitor.protocol === "dns" && monitor.server && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<GlobeIcon className="size-3.5 text-muted-foreground" />
<span>{monitor.server}</span>
</>
)}
{monitor.certInfo?.expires ? <CertExpiry cert={monitor.certInfo} /> : null}
</SheetDescription>
</SheetHeader>
<div className="grid gap-4">

View File

@@ -12,7 +12,7 @@ import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
import { $chartTime, $userSettings, defaultLayoutWidth, getUserChartTime } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
@@ -22,6 +22,9 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
// without a value the hidden select submits an empty string, which would persist
// a chart time that no longer loads any data (#2104)
const chartTime = getUserChartTime(userSettings)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -29,6 +32,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
const formData = new FormData(e.target as HTMLFormElement)
const data = Object.fromEntries(formData) as Partial<UserSettings>
await saveSettings(data)
// apply the saved default time period to the active charts
$chartTime.set(getUserChartTime())
setIsLoading(false)
}
@@ -122,7 +127,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<Label className="block" htmlFor="chartTime">
<Trans>Default time period</Trans>
</Label>
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
<Select name="chartTime" key={chartTime} defaultValue={chartTime}>
<SelectTrigger id="chartTime">
<SelectValue />
</SelectTrigger>

View File

@@ -11,6 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { ZfsCharts } from "./system/charts/storage-pool-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { WiFiChart } from "./system/charts/wifi-chart"
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
import {
LazyContainersTable,
@@ -135,6 +136,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<FanChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
<WiFiChart system={system} {...coreProps} />
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
</div>
@@ -211,7 +213,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<FanChart {...coreProps} />
<BatteryChart system={system} {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
</TabsContent>
@@ -221,6 +222,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<>
<div className="grid xl:grid-cols-2 gap-4">
<BandwidthChart {...coreProps} systemStats={systemStats} />
<WiFiChart system={system} {...coreProps} />
</div>
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>

View File

@@ -3,7 +3,7 @@ import { Fragment, type ReactNode, useRef, useMemo } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import LineChartDefault from "@/components/charts/line-chart"
import { Unit } from "@/lib/enums"
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
@@ -79,7 +79,6 @@ export function GpuPowerChart({
return (
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={t`GPU Power Draw`}
@@ -97,8 +96,8 @@ export function GpuPowerChart({
)
}
/** All GPU charts (optional power-draw slot + engines + per-GPU usage/VRAM) in a single 2-col grid, so the
* cards' odd:last-of-type parity rule flows across the whole tab and no row is left half-empty */
/** GPU charts: summary grid (optional power-draw slot + engines) above a per-GPU usage/VRAM grid. Separate
* grids keep each GPU's usage and VRAM cards paired, while odd:last-of-type stretches a lone summary card */
export function GpuCharts({
chartData,
grid,
@@ -114,7 +113,10 @@ export function GpuCharts({
hasGpuEnginesData: boolean
children?: ReactNode
}) {
const gpuIds = Object.keys(lastGpus)
return (
<div className="grid gap-4">
{(children || hasGpuEnginesData) && (
<div className="grid xl:grid-cols-2 gap-4">
{children}
{hasGpuEnginesData && (
@@ -128,7 +130,11 @@ export function GpuCharts({
<GpuEnginesChart chartData={chartData} />
</ChartCard>
)}
{Object.keys(lastGpus).map((id) => {
</div>
)}
{gpuIds.length > 0 && (
<div className="grid xl:grid-cols-2 gap-4">
{gpuIds.map((id) => {
const gpu = lastGpus[id] as GPUData
return (
<Fragment key={id}>
@@ -186,6 +192,8 @@ export function GpuCharts({
)
})}
</div>
)}
</div>
)
}

View File

@@ -27,6 +27,8 @@ type MonitorChartBaseProps = MonitorChartProps & {
tickFormatter: (value: number) => string
contentFormatter: ({ value }: { value: number | string }) => string | number
domain?: [number | "auto", number | "auto"]
/** Overrides the per-monitor line colors (e.g. a fixed color for single-monitor charts). */
color?: string
}
function MonitorChart({
@@ -41,6 +43,7 @@ function MonitorChart({
tickFormatter,
contentFormatter,
domain,
color,
showFilter = monitors.length > 1,
}: MonitorChartBaseProps) {
const storedFilter = useStore($monitorFilter)
@@ -67,11 +70,12 @@ function MonitorChart({
label,
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
dot,
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
color:
color ?? (count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`),
})
}
return { dataPoints: points, visibleKeys: visibleIDs }
}, [monitors, filter, metric, chartData.chartTime])
}, [monitors, filter, metric, chartData.chartTime, color])
const filteredMonitorStats = useMemo(() => {
if (!visibleKeys.length) return monitorStats
@@ -200,6 +204,7 @@ export function LossChart({ monitorStats, grid, monitors, chartData, empty, titl
title={title}
description={t`Packet loss (%)`}
domain={[0, 100]}
color="var(--destructive)"
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
contentFormatter={({ value }) => {
if (typeof value !== "number") {

View File

@@ -0,0 +1,46 @@
import { t } from "@lingui/core/macro"
import LineChartDefault from "@/components/charts/line-chart"
import { connectedWiFi, wifiColor } from "@/lib/wifi"
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
export function WiFiChart({
system,
chartData,
grid,
dataEmpty,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const interfaces = connectedWiFi(system)
// Associated interfaces may not report RSSI; without any readings the chart would never render.
const hasSignal = interfaces.some(
([id, wifi]) => wifi.r !== undefined || chartData.systemStats.some((record) => record.stats?.wf?.[id] !== undefined)
)
if (!hasSignal) return null
const dataPoints = interfaces.map(([id, current]) => ({
label: current.s ? `${id} (${current.s})` : id,
color: wifiColor(id),
dataKey: ({ stats }: SystemStatsRecord) => stats?.wf?.[id],
}))
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Wi-Fi signal`}
description={t`Signal strength of connected Wi-Fi interfaces`}
>
<LineChartDefault
chartData={chartData}
dataPoints={dataPoints}
domain={["auto", "auto"]}
legend={true}
tickFormatter={(value) => `${value} dBm`}
contentFormatter={({ value }) => `${value} dBm`}
/>
</ChartCard>
)
}

View File

@@ -161,7 +161,7 @@ export default function InfoBar({
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
{!!system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (

View File

@@ -14,6 +14,7 @@ import {
$maxValues,
$systems,
$userSettings,
getUserChartTime,
} from "@/lib/stores"
import { chartTimeData, listen, parseSemVer } from "@/lib/utils"
import type {
@@ -90,7 +91,7 @@ export function useSystemData(id: string) {
useEffect(() => {
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
$chartTime.set(getUserChartTime())
}
persistChartTime.current = false
setSystemStats([])

View File

@@ -1,5 +1,5 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
import { t } from "@lingui/core/macro"
import { plural, t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
@@ -14,6 +14,7 @@ import {
HardDriveIcon,
MemoryStickIcon,
MoreHorizontalIcon,
PackageIcon,
PauseCircleIcon,
PenBoxIcon,
PlayCircleIcon,
@@ -37,7 +38,8 @@ import {
secondsToUptimeString,
} from "@/lib/utils"
import { batteryStateTranslations } from "@/lib/i18n"
import type { SystemRecord } from "@/types"
import { connectedWiFi, strongestWiFi, strongestWiFiSignal, wifiSignalState } from "@/lib/wifi"
import type { SystemRecord, WiFi } from "@/types"
import { SystemDialog } from "../add-system"
import AlertButton from "../alerts/alert-button"
import { $router, Link } from "../router"
@@ -80,6 +82,15 @@ const STATUS_COLORS = {
[SystemStatus.Pending]: "bg-yellow-500",
} as const
/** Rank of the updates dot color for sorting: 2 security (red), 1 regular (yellow), 0 up to date (green), -1 no data */
function getUpdatesRank(pu: SystemRecord["info"]["pu"]): number {
if (!pu) {
return -1
}
const [total, security = 0] = pu
return security > 0 ? 2 : total > 0 ? 1 : 0
}
function getMeterStateByThresholds(value: number, warn = 65, crit = 90): MeterState {
return value >= crit ? MeterState.Crit : value >= warn ? MeterState.Warn : MeterState.Good
}
@@ -336,6 +347,57 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
)
},
},
{
accessorFn: strongestWiFiSignal,
id: "wifi",
name: () => t`Wi-Fi`,
size: 80,
Icon: WifiIcon,
header: sortableHeader,
hideSort: true,
sortUndefined: "last",
cell(info) {
const connections = connectedWiFi(info.row.original)
const strongest = strongestWiFi(connections)
if (!strongest) {
return null
}
const displayedConnections = viewMode === "table" ? [strongest] : connections
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "system", { id: info.row.original.id })}
tabIndex={-1}
className="flex flex-col gap-0.5 min-w-0 py-1 relative z-10"
>
{displayedConnections.map(([id, wifi]) => (
<WiFiSignal key={id} wifi={wifi} />
))}
{viewMode === "table" && connections.length > 1 && (
<span className="text-xs text-muted-foreground">+{connections.length - 1}</span>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs pb-2">
<div className="grid gap-1">
{connections.map(([id, wifi]) => (
<div key={id} className="grid gap-0.5">
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">
{id}
</div>
<div className="flex gap-2 items-center text-xs">
<WiFiSignal wifi={wifi} className="shrink-0" />
{wifi.s && <span className="truncate max-w-40">{wifi.s}</span>}
</div>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
},
},
{
accessorFn: ({ info }) => info.sv?.[0],
id: "services",
@@ -345,11 +407,13 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader,
hideSort: true,
sortingFn: (a, b) => {
// sort priorities: 1) failed services, 2) total services
// sort priorities: 1) has failed services (dot color), 2) total services
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0]
if (numFailedA !== numFailedB) {
return numFailedA - numFailedB
const hasFailedA = numFailedA > 0 ? 1 : 0
const hasFailedB = numFailedB > 0 ? 1 : 0
if (hasFailedA !== hasFailedB) {
return hasFailedA - hasFailedB
}
return totalCountA - totalCountB
},
@@ -359,18 +423,73 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
if (sys.status !== SystemStatus.Up || totalCount === 0) {
return null
}
const content = (
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
<span
className={cn("block size-2 rounded-full", {
[STATUS_COLORS.pending]: numFailed > 0,
[STATUS_COLORS.up]: numFailed === 0,
})}
/>
{plural(totalCount, { one: "# service", other: "# services" })}
</span>
)
if (numFailed === 0) {
return content
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "system", { id: sys.id })}
tabIndex={-1}
className="relative z-10 w-fit block"
>
{content}
</Link>
</TooltipTrigger>
<TooltipContent>
{plural(numFailed, { one: "# failed service", other: "# failed services" })}
</TooltipContent>
</Tooltip>
)
},
},
{
accessorFn: ({ info }) => info.pu?.[0],
id: "updates",
name: () => t`Updates`,
size: 50,
Icon: PackageIcon,
header: sortableHeader,
hideSort: true,
sortingFn: (a, b) => {
// sort priorities: 1) dot color (security > regular > up to date), 2) total updates
const puA = a.original.info.pu
const puB = b.original.info.pu
const rankA = getUpdatesRank(puA)
const rankB = getUpdatesRank(puB)
if (rankA !== rankB) {
return rankA - rankB
}
return (puA?.[0] ?? 0) - (puB?.[0] ?? 0)
},
cell(info) {
const sys = info.row.original
if (sys.status !== SystemStatus.Up || !sys.info.pu) {
return null
}
const [total, security = 0] = sys.info.pu
return (
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
<span
className={cn("block size-2 rounded-full", {
[STATUS_COLORS[SystemStatus.Down]]: numFailed > 0,
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
[STATUS_COLORS[SystemStatus.Down]]: security > 0,
[STATUS_COLORS[SystemStatus.Pending]]: security === 0 && total > 0,
[STATUS_COLORS[SystemStatus.Up]]: total === 0,
})}
/>
{totalCount}{" "}
<span className="text-muted-foreground text-sm -ms-0.5">
({t`Failed`.toLowerCase()}: {numFailed})
</span>
{total === 0 ? t`Up to date` : plural(total, { one: "# update", other: "# updates" })}
</span>
)
},
@@ -580,6 +699,23 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
)
}
function WiFiSignal({ wifi, className }: { wifi: WiFi; className?: ClassValue }) {
const state = wifi.r === undefined ? undefined : wifiSignalState(wifi.r)
return (
<span className={cn("flex items-center gap-1.5 tabular-nums whitespace-nowrap", className)}>
<span
className={cn("block size-2 rounded-full shrink-0", {
[STATUS_COLORS[SystemStatus.Up]]: state === MeterState.Good,
[STATUS_COLORS[SystemStatus.Pending]]: state === MeterState.Warn,
[STATUS_COLORS[SystemStatus.Down]]: state === MeterState.Crit,
[STATUS_COLORS[SystemStatus.Paused]]: state === undefined,
})}
/>
{wifi.r === undefined ? t`Unknown` : `${wifi.r} dBm`}
</span>
)
}
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
return (

View File

@@ -68,6 +68,7 @@
--breakpoint-xs: 26.6rem;
--breakpoint-450: 28rem;
--breakpoint-md-lg: 53rem;
--breakpoint-2xl: 90rem;
--radius-sm: calc(var(--radius) - 4px);
@@ -117,6 +118,7 @@
}
@layer utilities {
/* Fonts */
@supports (font-variation-settings: normal) {
:root {
@@ -139,6 +141,17 @@
overflow-anchor: none;
}
html {
scrollbar-gutter: stable;
}
@supports (scrollbar-gutter: stable) {
/* Radix scroll lock adds this margin even though the viewport keeps its gutter. */
html body[data-scroll-locked] {
margin-right: 0 !important;
}
}
body {
@apply bg-background text-foreground;
font-variant-ligatures: no-contextual;
@@ -172,6 +185,7 @@
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}

View File

@@ -4,7 +4,7 @@ import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings, hydrateUserSettings } from "./stores"
import { chartTimeData, debounce } from "./utils"
/** PocketBase JS Client */
@@ -90,7 +90,7 @@ export function queueUserSettings(newSettings: Partial<UserSettings>) {
export async function updateUserSettings() {
try {
const req = await pb.collection("user_settings").getFirstListItem("", { fields: "settings" })
$userSettings.set(req.settings)
hydrateUserSettings(req.settings)
dynamicActivate(req.settings.lang || getLocale())
return
} catch (e) {
@@ -99,7 +99,7 @@ export async function updateUserSettings() {
// create user settings if error fetching existing
try {
const createdSettings = await pb.collection("user_settings").create({ user: pb.authStore.record?.id })
$userSettings.set(createdSettings.settings)
hydrateUserSettings(createdSettings.settings)
dynamicActivate(createdSettings.settings.lang || getLocale())
} catch (e) {
console.error("create settings", e)

View File

@@ -1,4 +1,4 @@
import type { MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
@@ -15,3 +15,15 @@ export function getMonitorTarget(monitor: Pick<NetworkMonitorRecord, "target" |
const host = monitor.target.includes(":") && !monitor.target.startsWith("[") ? `[${monitor.target}]` : monitor.target
return `${host}:${monitor.port}`
}
/** Whole days until the certificate expires; negative once expired. */
export function getCertDaysLeft(cert: Pick<MonitorCertInfo, "expires">, now = Date.now()) {
return Math.floor((cert.expires - now) / 86_400_000)
}
/** Expiry severity used for certificate colors. */
export function getCertExpiryLevel(daysLeft: number): "ok" | "warning" | "critical" {
if (daysLeft < 7) return "critical"
if (daysLeft < 14) return "warning"
return "ok"
}

View File

@@ -1,4 +1,4 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import { atom, computed, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UpdateInfo, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
@@ -31,8 +31,11 @@ export const $publicKey = atom("")
/** New version info if an update is available, otherwise undefined */
export const $newVersion = atom<UpdateInfo | undefined>()
/** Chart time period used when user settings don't provide one */
export const defaultChartTime: ChartTimes = "1h"
/** Chart time period */
export const $chartTime = atom<ChartTimes>("1h")
export const $chartTime = atom<ChartTimes>(defaultChartTime)
/** Whether to display average or max chart values */
export const $maxValues = atom(false)
@@ -50,13 +53,25 @@ export const $maxValues = atom(false)
/** User settings */
export const $userSettings = map<UserSettings>({
chartTime: "1h",
chartTime: defaultChartTime,
emails: [pb.authStore.record?.email || ""],
unitNet: Unit.Bytes,
unitTemp: Unit.Celsius,
})
// update chart time on change
listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chartTime))
/** Chart time period stored in user settings, or the default if it's missing */
export function getUserChartTime(settings: UserSettings = $userSettings.get()): ChartTimes {
return settings.chartTime || defaultChartTime
}
/**
* Apply settings loaded from the database, including the default chart time.
* Other settings writes don't touch $chartTime so they can't reset the active chart range.
*/
export function hydrateUserSettings(settings: UserSettings) {
$userSettings.set(settings)
$chartTime.set(getUserChartTime(settings))
}
/** Container chart filter */
export const $containerFilter = atom("")
@@ -78,3 +93,8 @@ export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name string. Used to reserve width in virtualized tables. */
export const $longestSystemName = atom("")
/** Incremented when measured text widths are invalidated (e.g. web font finished loading).
* Anything that caches a comparison from isVisuallyLonger should recompute when this changes.
*/
export const $textMeasureVersion = atom(0)

View File

@@ -7,6 +7,7 @@ import {
$downSystems,
$longestSystemName,
$pausedSystems,
$textMeasureVersion,
$upSystems,
} from "@/lib/stores"
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
@@ -67,6 +68,11 @@ export function init() {
// run things that need to be done when systems change
onSystemsChanged(newSystems, newSystem, oldSystem)
})
// widths measured with the fallback font may rank names differently, so recompute once they're invalidated
$textMeasureVersion.listen(() => {
$longestSystemName.set(findLongestName($allSystemsById.get()))
})
}
/** Update the longest system name string and favicon based on system status */
@@ -78,13 +84,7 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
// otherwise, if the changed system's new name is longer than the current longest, update it
const longestName = $longestSystemName.get()
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
let newLongest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, newLongest)) {
newLongest = systems[id].name
}
}
$longestSystemName.set(newLongest)
$longestSystemName.set(findLongestName(systems))
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
$longestSystemName.set(newSystem.name)
}
@@ -92,6 +92,17 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
updateFavicon(downSystems.length)
}
/** Find the visually longest system name */
function findLongestName(systems: Record<string, SystemRecord>): string {
let longest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, longest)) {
longest = systems[id].name
}
}
return longest
}
/** Fetch systems from collection */
async function fetchSystems(): Promise<SystemRecord[]> {
try {

View File

@@ -74,7 +74,7 @@ async function fetchMonitorStats(
}
const NETWORK_MONITOR_FIELDS =
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,updated"
"id,system,target,protocol,port,server,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
interface UseNetworkMonitorsProps {
systemId?: string

View File

@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
import { $copyContent, $textMeasureVersion, $userSettings } from "./stores"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -451,6 +451,45 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
const visualWidthCache = new Map<string, number>()
let measureContext: CanvasRenderingContext2D | null | undefined
let measureFont = ""
/** Canvas context for measuring text in the font the app renders with, or null where canvas is unavailable.
* Only relative widths matter here, so the font size is arbitrary.
*/
function getMeasureContext(): CanvasRenderingContext2D | null {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d")
// the fallback font has different metrics, so re-measure whenever a font finishes loading.
// loadingdone also covers fonts that start loading after the first measurement,
// which fonts.ready does not if it has already resolved.
if (measureContext && "fonts" in document) {
document.fonts.addEventListener("loadingdone", invalidateVisualWidths)
}
}
if (measureContext) {
const { fontFamily, fontWeight } = getComputedStyle(document.body)
const font = `${fontWeight} 16px ${fontFamily}`
if (font !== measureFont) {
const isFirstFont = !measureFont
measureFont = font
measureContext.font = font
visualWidthCache.clear()
// defer so stores aren't updated in the middle of a comparison or a render
if (!isFirstFont) {
queueMicrotask(invalidateVisualWidths)
}
}
}
return measureContext
}
/** Drop cached widths and notify anything holding a result from isVisuallyLonger */
function invalidateVisualWidths() {
visualWidthCache.clear()
$textMeasureVersion.set($textMeasureVersion.get() + 1)
}
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
* Don't use for monospaced fonts, use .length instead
*/
@@ -459,6 +498,11 @@ function getVisualStringWidth(str: string): number {
if (cached !== undefined) {
return cached
}
const measured = getMeasureContext()?.measureText(str).width
if (measured !== undefined) {
visualWidthCache.set(str, measured)
return measured
}
let width = 0
for (const char of str) {
if (char === ".") {

View File

@@ -0,0 +1,37 @@
import { expect, test } from "bun:test"
import { MeterState } from "@/lib/enums"
import { connectedWiFi, strongestWiFiSignal, wifiColor, wifiSignalState } from "./wifi"
import type { SystemInfo } from "@/types"
const system = (wf?: SystemInfo["wf"], status: "up" | "down" = "up") => ({ status, info: { wf } as SystemInfo })
test("current state gates panel, not retained history", () => {
expect(connectedWiFi(system())).toEqual([])
expect(connectedWiFi(system(null))).toEqual([])
expect(connectedWiFi(system({}))).toEqual([])
expect(connectedWiFi(system({ wlan0: { r: -50 } }, "down"))).toEqual([])
expect(connectedWiFi(system({ wlan0: { r: -50 } }))).toHaveLength(1)
expect(connectedWiFi(system({}))).toHaveLength(0)
expect(connectedWiFi(system({ wlan0: { s: "new", r: -60 } }))[0][0]).toBe("wlan0")
})
test("multiple interfaces retain independent stable identities and colors", () => {
const connections = connectedWiFi(system({ wlan1: { s: "same" }, wlan0: { s: "same", r: -40 } }))
expect(connections.map(([id]) => id)).toEqual(["wlan0", "wlan1"])
expect(wifiColor(connections[0][0])).toBe(wifiColor("wlan0"))
expect(wifiColor("wlan0")).not.toBe(wifiColor("wlan1"))
})
test("strongestWiFiSignal returns the strongest current native RSSI", () => {
expect(strongestWiFiSignal(system({ wlan0: { r: -63 }, wlan1: { r: -48 }, wlan2: {} }))).toBe(-48)
expect(strongestWiFiSignal(system({ wlan0: {} }))).toBeUndefined()
expect(strongestWiFiSignal(system({ wlan0: { r: -48 } }, "down"))).toBeUndefined()
})
test("wifiSignalState thresholds", () => {
expect(wifiSignalState(-40)).toBe(MeterState.Good)
expect(wifiSignalState(-65)).toBe(MeterState.Good)
expect(wifiSignalState(-66)).toBe(MeterState.Warn)
expect(wifiSignalState(-75)).toBe(MeterState.Warn)
expect(wifiSignalState(-76)).toBe(MeterState.Crit)
})

View File

@@ -0,0 +1,34 @@
import { MeterState } from "@/lib/enums"
import type { SystemRecord, WiFi } from "@/types"
// Current system info is independent of the selected historical chart window.
// No fallback to history: missing data, disconnect and offline all hide the panel.
export function connectedWiFi(system: Pick<SystemRecord, "status" | "info">): [string, WiFi][] {
return system.status === "up" ? Object.entries(system.info?.wf ?? {}).sort(([a], [b]) => a.localeCompare(b)) : []
}
/** Strongest connection by RSSI, falling back to the first when none report a signal. */
export function strongestWiFi(connections: [string, WiFi][]): [string, WiFi] | undefined {
let strongest = connections[0]
for (const connection of connections) {
if ((connection[1].r ?? -Infinity) > (strongest[1].r ?? -Infinity)) {
strongest = connection
}
}
return strongest
}
export function strongestWiFiSignal(system: Pick<SystemRecord, "status" | "info">): number | undefined {
return strongestWiFi(connectedWiFi(system))?.[1].r
}
/** Signal quality for an RSSI reading: good at -65 dBm or stronger, warn down to -75 dBm, crit below. */
export function wifiSignalState(rssi: number): MeterState {
return rssi >= -65 ? MeterState.Good : rssi >= -75 ? MeterState.Warn : MeterState.Crit
}
export function wifiColor(id: string): string {
let hash = 0
for (const char of id) hash = (Math.imul(hash, 31) + char.charCodeAt(0)) | 0
return `hsl(${(hash >>> 0) % 360}, 65%, 52%)`
}

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:22\n"
"PO-Revision-Date: 2026-09-24 16:16\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} ساعة} other {{countString} ساع
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} دقيقة} few {{countString} دقائق} many {{countString} دقيقة} other {{countString} دقيقة}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "{diskName} إدخال/إخراج"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# خيط} other {# خيط}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 ساعة"
@@ -123,10 +135,10 @@ msgstr "التنبيهات النشطة"
msgid "Active state"
msgstr "الحالة النشطة"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "الحالة النشطة"
msgid "Add {foo}"
msgstr "إضافة {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "إضافة النظام"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "إضافة رابط"
@@ -260,6 +279,7 @@ msgstr "متوسط استغلال محركات GPU"
msgid "Average, minimum, and maximum response time"
msgstr "متوسط زمن الاستجابة والأدنى والأقصى"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "متوسط 1h"
@@ -383,6 +403,19 @@ msgstr "تحذير - فقدان محتمل للبيانات"
msgid "Celsius (°C)"
msgstr "درجة مئوية (°م)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "تغيير وحدات عرض المقاييس."
@@ -776,12 +809,15 @@ msgstr "المدة"
msgid "Edit"
msgstr "تعديل"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "إضافة {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "إضافة النظام"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في
msgid "Exited active"
msgstr "خرج نشطًا"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "ينتهي بعد ساعة واحدة أو عند إعادة تشغيل المحور."
@@ -887,10 +927,6 @@ msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
msgid "Fahrenheit (°F)"
msgstr "فهرنهايت (°ف)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "فشل"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "السمات الفاشلة:"
@@ -1090,6 +1126,7 @@ msgstr "يتوفر تحديث للصورة"
msgid "Inactive"
msgstr "غير نشط"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "الفقد"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "الفقد 1h"
@@ -1204,6 +1242,7 @@ msgstr "تعليمات الإعداد اليدوي"
msgid "Max 1 min"
msgstr "الحد الأقصى دقيقة"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "الأقصى 1h"
@@ -1234,6 +1273,7 @@ msgstr "استخدام الذاكرة"
msgid "Memory usage of containers"
msgstr "استخدام الذاكرة للحاويات"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "الأدنى 1h"
@@ -1565,6 +1605,7 @@ msgstr "تم بدء العملية"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "البروتوكول"
@@ -1653,6 +1694,7 @@ msgstr "إعادة تعيين كلمة المرور"
msgid "Resolved"
msgstr "تم حلها"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "تفاصيل S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test"
msgstr "اختبار S.M.A.R.T. الذاتي"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "حفظ {foo}"
@@ -1712,6 +1753,10 @@ msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو
msgid "Save Settings"
msgstr "حفظ الإعدادات"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "حفظ النظام"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "محفوظ في قاعدة البيانات ولا ينتهي حتى تقوم بتعطيله."
@@ -1833,6 +1878,7 @@ msgstr "تسجيل الدخول"
msgid "SMTP settings"
msgstr "إعدادات SMTP"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "الترتيب حسب"
@@ -1875,12 +1921,11 @@ msgstr "استخدام التبديل"
msgid "Switch theme"
msgstr "تبديل السمة"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "تبويبات"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "الهدف"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "المهام"
@@ -2162,6 +2213,7 @@ msgstr "تحديث"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "الاستخدام"
msgid "Value"
msgstr "القيمة"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "عرض"
@@ -2231,6 +2284,7 @@ msgstr "عرض المزيد"
msgid "View your 200 most recent alerts."
msgstr "عرض أحدث 200 تنبيه."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "الأعمدة الظاهرة"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"PO-Revision-Date: 2026-09-24 16:15\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} час} other {{countString} часа
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} минута} few {{countString} минути} many {{countString} минути} other {{countString} минути}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "В/И на {diskName}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# нишка} other {# нишки}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 час"
@@ -123,10 +135,10 @@ msgstr "Активни тревоги"
msgid "Active state"
msgstr "Активно състояние"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Активно състояние"
msgid "Add {foo}"
msgstr "Добави {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Добави Система"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Добави URL"
@@ -260,6 +279,7 @@ msgstr "Средно използване на GPU двигатели"
msgid "Average, minimum, and maximum response time"
msgstr "Средно, минимално и максимално време за отговор"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Средно 1h"
@@ -383,6 +403,19 @@ msgstr "Внимание - възможност за загуба на данн
msgid "Celsius (°C)"
msgstr "Целзий (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Промяна на единиците за показване на метриките."
@@ -776,12 +809,15 @@ msgstr "Продължителност"
msgid "Edit"
msgstr "Редактирай"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Редактиране на {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Редактиране на Система"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Съществуващи системи които не са дефин
msgid "Exited active"
msgstr "Излезе активно"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Изтича след един час или при рестартиране на хъба."
@@ -887,10 +927,6 @@ msgstr "Експортирай конфигурацията на системи
msgid "Fahrenheit (°F)"
msgstr "Фаренхайт (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Неуспешно"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Неуспешни атрибути:"
@@ -1090,6 +1126,7 @@ msgstr "Налична актуализация на образа"
msgid "Inactive"
msgstr "Неактивен"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Загуба"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Загуба 1h"
@@ -1204,6 +1242,7 @@ msgstr "Инструкции за ръчна настройка"
msgid "Max 1 min"
msgstr "Максимум 1 минута"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Макс. 1h"
@@ -1234,6 +1273,7 @@ msgstr "Употреба на паметта"
msgid "Memory usage of containers"
msgstr "Използване на паметта от контейнерите"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Мин. 1h"
@@ -1565,6 +1605,7 @@ msgstr "Процесът стартира"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Протокол"
@@ -1653,6 +1694,7 @@ msgstr "Нулиране на парола"
msgid "Resolved"
msgstr "Решен"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "S.M.A.R.T. Детайли"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Самотест"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Запази {foo}"
@@ -1712,6 +1753,10 @@ msgstr "Запази адреса с enter или запетая. Остави
msgid "Save Settings"
msgstr "Запази настройките"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Запази Система"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Запазен е в базата данни и не изтича, докато не го деактивирате."
@@ -1833,6 +1878,7 @@ msgstr "Влез"
msgid "SMTP settings"
msgstr "Настройки за SMTP"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Сортиране по"
@@ -1875,12 +1921,11 @@ msgstr "Използване на swap"
msgid "Switch theme"
msgstr "Смени темата"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Табове"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Цел"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Задачи"
@@ -2162,6 +2213,7 @@ msgstr "Актуализирай"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Натоварване"
msgid "Value"
msgstr "Стойност"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Изглед"
@@ -2231,6 +2284,7 @@ msgstr "Виж повече"
msgid "View your 200 most recent alerts."
msgstr "Прегледайте последните си 200 сигнала."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Видими полета"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"PO-Revision-Date: 2026-09-24 16:15\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} Hodina} few {{countString} Hodiny} ma
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} minuta} few {{countString} minuty} many {{countString} minut} other {{countString} minut}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "I/O {diskName}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# vlákno} few {# vlákna} many {# vláken} other {# vláken}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 hodina"
@@ -123,10 +135,10 @@ msgstr "Aktivní výstrahy"
msgid "Active state"
msgstr "Aktivní stav"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Aktivní stav"
msgid "Add {foo}"
msgstr "Přidat {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Přidat Systém"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Přidat URL"
@@ -260,6 +279,7 @@ msgstr "Průměrné využití GPU engine"
msgid "Average, minimum, and maximum response time"
msgstr "Průměrná, minimální a maximální doba odezvy"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Prům. 1h"
@@ -383,6 +403,19 @@ msgstr "Upozornění - možná ztráta dat"
msgid "Celsius (°C)"
msgstr "Celsia (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Změnit jednotky zobrazení metrik."
@@ -776,12 +809,15 @@ msgstr "Doba trvání"
msgid "Edit"
msgstr "Upravit"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Upravit {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Upravit Systém"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, bu
msgid "Exited active"
msgstr "Ukončeno aktivně"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Vyprší po jedné hodině nebo při restartu hubu."
@@ -887,10 +927,6 @@ msgstr "Exportovat aktuální konfiguraci systémů."
msgid "Fahrenheit (°F)"
msgstr "Fahrenheita (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Selhalo"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Neúspěšné atributy:"
@@ -1090,6 +1126,7 @@ msgstr "K dispozici je aktualizace obrazu"
msgid "Inactive"
msgstr "Neaktivní"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Ztráta"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Ztráta 1h"
@@ -1204,6 +1242,7 @@ msgstr "Pokyny k manuálnímu nastavení"
msgid "Max 1 min"
msgstr "Max. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max. 1h"
@@ -1234,6 +1273,7 @@ msgstr "Využití paměti"
msgid "Memory usage of containers"
msgstr "Využití paměti kontejnery"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min. 1h"
@@ -1565,6 +1605,7 @@ msgstr "Proces spuštěn"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
@@ -1653,6 +1694,7 @@ msgstr "Obnovit heslo"
msgid "Resolved"
msgstr "Vyřešeno"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "S.M.A.R.T. Detaily"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Vlastní test"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Uložit {foo}"
@@ -1712,6 +1753,10 @@ msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mai
msgid "Save Settings"
msgstr "Uložit nastavení"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Uložit Systém"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Uložen v databázi a nevyprší, dokud jej nezablokujete."
@@ -1833,6 +1878,7 @@ msgstr "Přihlásit se"
msgid "SMTP settings"
msgstr "Nastavení SMTP"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Seřadit podle"
@@ -1875,12 +1921,11 @@ msgstr "Swap využití"
msgid "Switch theme"
msgstr "Přepnout motiv"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Karty"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Cíl"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Úlohy"
@@ -2162,6 +2213,7 @@ msgstr "Aktualizovat"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Využití"
msgid "Value"
msgstr "Hodnota"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Zobrazení"
@@ -2231,6 +2284,7 @@ msgstr "Zobrazit více"
msgid "View your 200 most recent alerts."
msgstr "Zobrazit vašich 200 nejnovějších upozornění."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Viditelné sloupce"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"PO-Revision-Date: 2026-09-24 16:15\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} time} other {{countString} timer}}"
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} minut} other {{countString} minutter}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "{diskName} I/O"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# tråd} other {# tråde}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 time"
@@ -123,10 +135,10 @@ msgstr "Aktive Alarmer"
msgid "Active state"
msgstr "Aktiv tilstand"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Aktiv tilstand"
msgid "Add {foo}"
msgstr "Tilføj {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Tilføj System"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Tilføj URL"
@@ -260,6 +279,7 @@ msgstr "Gennemsnitlig udnyttelse af GPU-enheder"
msgid "Average, minimum, and maximum response time"
msgstr "Gennemsnitlig, minimum og maksimum svartid"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Gns 1h"
@@ -383,6 +403,19 @@ msgstr "Forsigtig - muligt tab af data"
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Ændre viste enheder for målinger."
@@ -776,12 +809,15 @@ msgstr "Varighed"
msgid "Edit"
msgstr "Rediger"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Rediger {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Rediger System"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Eksisterende systemer ikke defineret i <0>config.yml</0> vil blive slett
msgid "Exited active"
msgstr "Afsluttet aktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Udløber efter en time eller ved hub-genstart."
@@ -887,10 +927,6 @@ msgstr "Eksporter din nuværende systemkonfiguration."
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Mislykkedes"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Mislykkede attributter:"
@@ -1090,6 +1126,7 @@ msgstr "Imageopdatering tilgængelig"
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Tab"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Tab 1h"
@@ -1204,6 +1242,7 @@ msgstr "Manuel opsætningsvejledning"
msgid "Max 1 min"
msgstr "Maks. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Maks 1h"
@@ -1234,6 +1273,7 @@ msgstr "Hukommelsesforbrug"
msgid "Memory usage of containers"
msgstr "Containeres hukommelsesforbrug"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr ""
@@ -1565,6 +1605,7 @@ msgstr "Proces startet"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokol"
@@ -1653,6 +1694,7 @@ msgstr "Nulstil adgangskode"
msgid "Resolved"
msgstr "Løst"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "S.M.A.R.T.-detaljer"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. selvtest"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Gem {foo}"
@@ -1712,6 +1753,10 @@ msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at
msgid "Save Settings"
msgstr "Gem indstillinger"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Gem System"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Gemt i databasen og udløber ikke, før du deaktiverer det."
@@ -1833,6 +1878,7 @@ msgstr "Log ind"
msgid "SMTP settings"
msgstr "SMTP-indstillinger"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Sorter efter"
@@ -1875,12 +1921,11 @@ msgstr "Swap forbrug"
msgid "Switch theme"
msgstr "Skift tema"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Faner"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Mål"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Opgaver"
@@ -2162,6 +2213,7 @@ msgstr "Opdater"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Udnyttelse"
msgid "Value"
msgstr "Værdi"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Vis"
@@ -2231,6 +2284,7 @@ msgstr "Se mere"
msgid "View your 200 most recent alerts."
msgstr "Se dine 200 nyeste alarmer."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Synlige felter"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:22\n"
"PO-Revision-Date: 2026-09-24 16:16\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} Stunde} other {{countString} Stunden}
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} Minute} other {{countString} Minuten}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "{diskName} E/A"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# Thread} other {# Threads}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 Stunde"
@@ -123,10 +135,10 @@ msgstr "Aktive Warnungen"
msgid "Active state"
msgstr "Aktiver Zustand"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Aktiver Zustand"
msgid "Add {foo}"
msgstr "{foo} hinzufügen"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "System hinzufügen"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "URL hinzufügen"
@@ -260,6 +279,7 @@ msgstr "Durchschnittliche Auslastung der GPU-Engines"
msgid "Average, minimum, and maximum response time"
msgstr "Durchschnittliche, minimale und maximale Antwortzeit"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Ø 1h"
@@ -383,6 +403,19 @@ msgstr "Vorsicht - potenzieller Datenverlust"
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Anzeigeeinheiten der Werte ändern."
@@ -776,12 +809,15 @@ msgstr "Dauer"
msgid "Edit"
msgstr "Bearbeiten"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "{foo} bearbeiten"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "System bearbeiten"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Bestehende Systeme, die nicht in der <0>config.yml</0> definiert sind, w
msgid "Exited active"
msgstr "Beendet aktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Läuft nach einer Stunde oder bei Hub-Neustart ab."
@@ -887,10 +927,6 @@ msgstr "Exportiere die aktuelle Systemkonfiguration."
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Fehlgeschlagen"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Fehlgeschlagene Attribute:"
@@ -1090,6 +1126,7 @@ msgstr "Image-Update verfügbar"
msgid "Inactive"
msgstr "Inaktiv"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Verlust"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Verlust 1h"
@@ -1204,6 +1242,7 @@ msgstr "Anleitung zur manuellen Einrichtung"
msgid "Max 1 min"
msgstr "Max 1 Min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr ""
@@ -1234,6 +1273,7 @@ msgstr "Arbeitsspeichernutzung"
msgid "Memory usage of containers"
msgstr "Speichernutzung der Container"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr ""
@@ -1565,6 +1605,7 @@ msgstr "Prozess gestartet"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protokoll"
@@ -1653,6 +1694,7 @@ msgstr "Passwort zurücksetzen"
msgid "Resolved"
msgstr "Gelöst"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "S.M.A.R.T.-Details"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T.-Selbsttest"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "{foo} speichern"
@@ -1712,6 +1753,10 @@ msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail
msgid "Save Settings"
msgstr "Einstellungen speichern"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "System speichern"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "In der Datenbank gespeichert und läuft nicht ab, bis Sie es deaktivieren."
@@ -1833,6 +1878,7 @@ msgstr "Anmelden"
msgid "SMTP settings"
msgstr "SMTP-Einstellungen"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Sortieren nach"
@@ -1875,12 +1921,11 @@ msgstr "Swap-Nutzung"
msgid "Switch theme"
msgstr "Design wechseln"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Tabs"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Ziel"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Aufgaben"
@@ -2162,6 +2213,7 @@ msgstr "Aktualisieren"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Auslastung"
msgid "Value"
msgstr "Wert"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Ansicht"
@@ -2231,6 +2284,7 @@ msgstr "Mehr anzeigen"
msgid "View your 200 most recent alerts."
msgstr "Sieh dir die neusten 200 Alarme an."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Sichtbare Spalten"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: el\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"PO-Revision-Date: 2026-09-24 16:15\n"
"Last-Translator: \n"
"Language-Team: Greek\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} ώρα} other {{countString} ώρες
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} λεπτό} other {{countString} λεπτά}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "I/O {diskName}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 ώρα"
@@ -123,10 +135,10 @@ msgstr "Ενεργές ειδοποιήσεις"
msgid "Active state"
msgstr "Ενεργή κατάσταση"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Ενεργή κατάσταση"
msgid "Add {foo}"
msgstr "Προσθήκη {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Προσθήκη Σύστημα"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Προσθήκη URL"
@@ -260,6 +279,7 @@ msgstr "Μέση χρήση των μηχανών GPU"
msgid "Average, minimum, and maximum response time"
msgstr "Μέσος, ελάχιστος και μέγιστος χρόνος απόκρισης"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Μέσος όρος 1ώρου"
@@ -383,6 +403,19 @@ msgstr "Προσοχή - πιθανή απώλεια δεδομένων"
msgid "Celsius (°C)"
msgstr "Κελσίου (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Αλλάξτε τις μονάδες εμφάνισης των μετρήσεων."
@@ -776,12 +809,15 @@ msgstr "Διάρκεια"
msgid "Edit"
msgstr "Επεξεργασία"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Επεξεργασία {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Επεξεργασία Σύστημα"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Τα υπάρχοντα συστήματα που δεν ορίζοντ
msgid "Exited active"
msgstr "Ενεργό μετά την έξοδο"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Λήγει μετά από μία ώρα ή με επανεκκίνηση του κόμβου."
@@ -887,10 +927,6 @@ msgstr "Εξαγάγετε την τρέχουσα διαμόρφωση των
msgid "Fahrenheit (°F)"
msgstr "Φαρενάιτ (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Απέτυχε"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Αποτυχημένα χαρακτηριστικά:"
@@ -1090,6 +1126,7 @@ msgstr "Διατίθεται ενημέρωση εικόνας"
msgid "Inactive"
msgstr "Ανενεργό"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Απώλεια"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Απώλεια 1ώρου"
@@ -1204,6 +1242,7 @@ msgstr "Οδηγίες χειροκίνητης εγκατάστασης"
msgid "Max 1 min"
msgstr "Μέγ. 1 λ"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Μέγιστο 1ώρου"
@@ -1234,6 +1273,7 @@ msgstr "Χρήση μνήμης"
msgid "Memory usage of containers"
msgstr "Χρήση μνήμης των κοντέινερ"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Ελάχιστο 1ώρου"
@@ -1565,6 +1605,7 @@ msgstr "Η διεργασία ξεκίνησε"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Πρωτόκολλο"
@@ -1653,6 +1694,7 @@ msgstr "Επαναφορά κωδικού πρόσβασης"
msgid "Resolved"
msgstr "Επιλύθηκε"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "Λεπτομέρειες S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test"
msgstr "Αυτοέλεγχος S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Αποθήκευση {foo}"
@@ -1712,6 +1753,10 @@ msgstr "Αποθηκεύστε τη διεύθυνση με Enter ή κόμμα.
msgid "Save Settings"
msgstr "Αποθήκευση ρυθμίσεων"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Αποθήκευση Σύστημα"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Αποθηκεύεται στη βάση δεδομένων και δεν λήγει μέχρι να το απενεργοποιήσετε."
@@ -1833,6 +1878,7 @@ msgstr "Σύνδεση"
msgid "SMTP settings"
msgstr "Ρυθμίσεις SMTP"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Ταξινόμηση κατά"
@@ -1875,12 +1921,11 @@ msgstr "Χρήση swap"
msgid "Switch theme"
msgstr "Αλλαγή θέματος"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Καρτέλες"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Στόχος"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Εργασίες"
@@ -2162,6 +2213,7 @@ msgstr "Ενημέρωση"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Χρήση"
msgid "Value"
msgstr "Τιμή"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Προβολή"
@@ -2231,6 +2284,7 @@ msgstr "Προβολή περισσότερων"
msgid "View your 200 most recent alerts."
msgstr "Προβάλετε τις 200 πιο πρόσφατες ειδοποιήσεις σας."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Ορατά πεδία"

View File

@@ -47,14 +47,26 @@ msgstr "{count, plural, one {{countString} hour} other {{countString} hours}}"
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr "{daysLeft, plural, one {# day} other {# days}}"
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "{diskName} I/O"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr "{numFailed, plural, one {# failed service} other {# failed services}}"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# thread} other {# threads}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr "{totalCount, plural, one {# service} other {# services}}"
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 hour"
@@ -118,10 +130,10 @@ msgstr "Active Alerts"
msgid "Active state"
msgstr "Active state"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr "Add"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -131,6 +143,13 @@ msgstr "Active state"
msgid "Add {foo}"
msgstr "Add {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Add System"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Add URL"
@@ -255,6 +274,7 @@ msgstr "Average utilization of GPU engines"
msgid "Average, minimum, and maximum response time"
msgstr "Average, minimum, and maximum response time"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Avg 1h"
@@ -378,6 +398,19 @@ msgstr "Caution - potential data loss"
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr "Certificate"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr "Certificate expired {expires}"
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr "Certificate expires {expires}"
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Change display units for metrics."
@@ -771,12 +804,15 @@ msgstr "Duration"
msgid "Edit"
msgstr "Edit"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Edit {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Edit System"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -862,6 +898,10 @@ msgstr "Existing systems not defined in <0>config.yml</0> will be deleted. Pleas
msgid "Exited active"
msgstr "Exited active"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr "Expired"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Expires after one hour or on hub restart."
@@ -882,10 +922,6 @@ msgstr "Export your current systems configuration."
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Failed"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Failed Attributes:"
@@ -1085,6 +1121,7 @@ msgstr "Image update available"
msgid "Inactive"
msgstr "Inactive"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1177,6 +1214,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Loss"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Loss 1h"
@@ -1199,6 +1237,7 @@ msgstr "Manual setup instructions"
msgid "Max 1 min"
msgstr "Max 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Max 1h"
@@ -1229,6 +1268,7 @@ msgstr "Memory Usage"
msgid "Memory usage of containers"
msgstr "Memory usage of containers"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Min 1h"
@@ -1560,6 +1600,7 @@ msgstr "Process started"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protocol"
@@ -1648,6 +1689,7 @@ msgstr "Reset Password"
msgid "Resolved"
msgstr "Resolved"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1693,7 +1735,6 @@ msgstr "S.M.A.R.T. Details"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Self-Test"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Save {foo}"
@@ -1707,6 +1748,10 @@ msgstr "Save address using enter key or comma. Leave blank to disable email noti
msgid "Save Settings"
msgstr "Save Settings"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Save System"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Saved in the database and does not expire until you disable it."
@@ -1828,6 +1873,7 @@ msgstr "Sign in"
msgid "SMTP settings"
msgstr "SMTP settings"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Sort By"
@@ -1870,12 +1916,11 @@ msgstr "Swap Usage"
msgid "Switch theme"
msgstr "Switch theme"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1920,9 +1965,15 @@ msgstr "Tabs"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Target"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr "target[,protocol[,port[,interval]]]"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tasks"
@@ -2157,6 +2208,7 @@ msgstr "Update"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2212,6 +2264,7 @@ msgstr "Utilization"
msgid "Value"
msgstr "Value"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "View"
@@ -2226,6 +2279,7 @@ msgstr "View more"
msgid "View your 200 most recent alerts."
msgstr "View your 200 most recent alerts."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Visible Fields"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-09-18 19:21\n"
"PO-Revision-Date: 2026-09-24 16:15\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -52,14 +52,26 @@ msgstr "{count, plural, one {{countString} hora} other {{countString} horas}}"
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} minuto} other {{countString} minutos}}"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "{daysLeft, plural, one {# day} other {# days}}"
msgstr ""
#: src/components/routes/system/charts/disk-charts.tsx
msgid "{diskName} I/O"
msgstr "E/S de {diskName}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
msgstr ""
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# hilo} other {# hilos}}"
#: src/components/systems-table/systems-table-columns.tsx
msgid "{totalCount, plural, one {# service} other {# services}}"
msgstr ""
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 hora"
@@ -123,10 +135,10 @@ msgstr "Alertas activas"
msgid "Active state"
msgstr "Estado activo"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Add"
msgstr ""
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
@@ -136,6 +148,13 @@ msgstr "Estado activo"
msgid "Add {foo}"
msgstr "Agregar {foo}"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Add System"
msgstr "Agregar Sistema"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Agregar URL"
@@ -260,6 +279,7 @@ msgstr "Utilización promedio de motores GPU"
msgid "Average, minimum, and maximum response time"
msgstr "Tiempo de respuesta medio, mínimo y máximo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Avg 1h"
msgstr "Prom. 1 h"
@@ -383,6 +403,19 @@ msgstr "Precaución - posible pérdida de datos"
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Certificate"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expired {expires}"
msgstr ""
#: src/components/network-monitors-table/network-monitors-table.tsx
msgid "Certificate expires {expires}"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
msgstr "Cambiar las unidades de visualización de las métricas."
@@ -776,12 +809,15 @@ msgstr "Duración"
msgid "Edit"
msgstr "Editar"
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "Editar {foo}"
#: src/components/add-system.tsx
msgid "Edit System"
msgstr "Editar Sistema"
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
@@ -867,6 +903,10 @@ msgstr "Los sistemas existentes no definidos en <0>config.yml</0> serán elimina
msgid "Exited active"
msgstr "Salió activo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Expired"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "Expira después de una hora o al reiniciar el hub."
@@ -887,10 +927,6 @@ msgstr "Exporta la configuración actual de sus sistemas."
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
msgstr "Fallido"
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr "Atributos fallidos:"
@@ -1090,6 +1126,7 @@ msgstr "Actualización de imagen disponible"
msgid "Inactive"
msgstr "Inactivo"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
@@ -1182,6 +1219,7 @@ msgctxt "Packet loss"
msgid "Loss"
msgstr "Pérdida"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Loss 1h"
msgstr "Pérdida 1 h"
@@ -1204,6 +1242,7 @@ msgstr "Instrucciones manuales de configuración"
msgid "Max 1 min"
msgstr "Máx. 1 min"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Max 1h"
msgstr "Máx. 1 h"
@@ -1234,6 +1273,7 @@ msgstr "Uso de memoria"
msgid "Memory usage of containers"
msgstr "Uso de memoria de los contenedores"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Min 1h"
msgstr "Mín. 1 h"
@@ -1565,6 +1605,7 @@ msgstr "Proceso iniciado"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Protocol"
msgstr "Protocolo"
@@ -1653,6 +1694,7 @@ msgstr "Restablecer contraseña"
msgid "Resolved"
msgstr "Resuelto"
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/charts/monitors-charts.tsx
msgid "Response"
@@ -1698,7 +1740,6 @@ msgstr "Detalles S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test"
msgstr "Autoprueba S.M.A.R.T."
#: src/components/add-system.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "Save {foo}"
msgstr "Guardar {foo}"
@@ -1712,6 +1753,10 @@ msgstr "Guarda la dirección usando la tecla enter o coma. Deja en blanco para d
msgid "Save Settings"
msgstr "Guardar configuración"
#: src/components/add-system.tsx
msgid "Save System"
msgstr "Guardar Sistema"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Guardado en la base de datos y no expira hasta que lo desactives."
@@ -1833,6 +1878,7 @@ msgstr "Iniciar sesión"
msgid "SMTP settings"
msgstr "Configuración SMTP"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Sort By"
msgstr "Ordenar por"
@@ -1875,12 +1921,11 @@ msgstr "Uso de swap"
msgid "Switch theme"
msgstr "Cambiar tema"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1925,9 +1970,15 @@ msgstr "Pestañas"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
msgid "Target"
msgstr "Objetivo"
#: src/components/network-monitors-table/monitor-dialog.tsx
#: src/components/network-monitors-table/monitor-dialog.tsx
msgid "target[,protocol[,port[,interval]]]"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tareas"
@@ -2162,6 +2213,7 @@ msgstr "Actualizar"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/network-monitors-table/network-monitors-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/routes/system/storage-pools-table.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
@@ -2217,6 +2269,7 @@ msgstr "Utilización"
msgid "Value"
msgstr "Valor"
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "View"
msgstr "Vista"
@@ -2231,6 +2284,7 @@ msgstr "Ver más"
msgid "View your 200 most recent alerts."
msgstr "Ver tus 200 alertas más recientes."
#: src/components/network-monitors-table/network-monitors-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
msgstr "Columnas visibles"

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