mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-26 03:17:49 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f2177b08 | ||
|
|
a042e19549 | ||
|
|
fe83f5b831 | ||
|
|
46fa7c581e | ||
|
|
24792aa24f | ||
|
|
16e3fbadce | ||
|
|
86ab0fae8b | ||
|
|
badd4c8245 | ||
|
|
7bea20e3b6 | ||
|
|
433b83800f | ||
|
|
3dfe062ee4 | ||
|
|
3fb97b800c | ||
|
|
d708def38f | ||
|
|
f50fb4f8e5 | ||
|
|
c25408651f | ||
|
|
d591da46f3 | ||
|
|
d80a2f49f9 | ||
|
|
21b648a005 | ||
|
|
151423ac63 | ||
|
|
f7528a0208 | ||
|
|
a20a7d2edc | ||
|
|
f2adb9cf94 | ||
|
|
4d10ea2e03 | ||
|
|
b5ef015451 | ||
|
|
6141b15f03 | ||
|
|
c21412f45d | ||
|
|
367d2f39da | ||
|
|
eabd9a950a | ||
|
|
0be9882b34 | ||
|
|
e4b84b72ab | ||
|
|
fc33e62736 | ||
|
|
a99fe5e997 | ||
|
|
0870716052 | ||
|
|
b1270e341c | ||
|
|
9042a8c5c8 | ||
|
|
8bf6917fe0 | ||
|
|
2d5ea3fa08 | ||
|
|
627d364071 | ||
|
|
8047f005d4 | ||
|
|
4a4610bbc3 | ||
|
|
1aaabfc255 | ||
|
|
97ea3c16cb | ||
|
|
c9de35fad2 | ||
|
|
a5f216f425 | ||
|
|
cbe4824ac3 | ||
|
|
2c69197d2d | ||
|
|
97e6f64bdc | ||
|
|
4a5915b141 | ||
|
|
e68372dce4 | ||
|
|
c52f3acb94 | ||
|
|
c09eb8c6df |
@@ -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 {
|
||||
|
||||
@@ -155,6 +155,7 @@ func (c *ConnectionManager) stop() error {
|
||||
_ = c.agent.StopServer()
|
||||
c.agent.monitorManager.Stop()
|
||||
c.closeWebSocket()
|
||||
c.agent.cleanupSensorShadow()
|
||||
return health.CleanUp()
|
||||
}
|
||||
|
||||
|
||||
120
agent/disk.go
120
agent/disk.go
@@ -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
125
agent/disk_io_linux_test.go
Normal 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
27
agent/gpu.go
27
agent/gpu.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
74
agent/network_monitor_cert.go
Normal file
74
agent/network_monitor_cert.go
Normal 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
|
||||
}
|
||||
184
agent/network_monitor_cert_test.go
Normal file
184
agent/network_monitor_cert_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
310
agent/package_updates.go
Normal 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
|
||||
}
|
||||
196
agent/package_updates_test.go
Normal file
196
agent/package_updates_test.go
Normal 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())
|
||||
}
|
||||
125
agent/sensors.go
125
agent/sensors.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
101
agent/test-data/package_updates/apt_debian12.txt
Normal file
101
agent/test-data/package_updates/apt_debian12.txt
Normal 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])
|
||||
130
agent/test-data/package_updates/apt_ubuntu2204.txt
Normal file
130
agent/test-data/package_updates/apt_ubuntu2204.txt
Normal 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])
|
||||
111
agent/test-data/package_updates/dnf4_rocky9_check_update.txt
Normal file
111
agent/test-data/package_updates/dnf4_rocky9_check_update.txt
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
4
agent/test-data/package_updates/pacman_checkupdates.txt
Normal file
4
agent/test-data/package_updates/pacman_checkupdates.txt
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
11
agent/wifi/README.md
Normal 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
46
agent/wifi/wifi.go
Normal 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
49
agent/wifi/wifi_darwin.go
Normal 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
134
agent/wifi/wifi_linux.go
Normal 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
|
||||
}
|
||||
142
agent/wifi/wifi_linux_test.go
Normal file
142
agent/wifi/wifi_linux_test.go
Normal 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
57
agent/wifi/wifi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
11
agent/wifi/wifi_unsupported.go
Normal file
11
agent/wifi/wifi_unsupported.go
Normal 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 }
|
||||
99
agent/wifi/wifi_windows.go
Normal file
99
agent/wifi/wifi_windows.go
Normal 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
5
go.mod
@@ -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
8
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
40
internal/entities/system/wifi_test.go
Normal file
40
internal/entities/system/wifi_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
internal/hub/systems/system_sync_name_test.go
Normal file
34
internal/hub/systems/system_sync_name_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
28
internal/hub/systems/system_wifi_test.go
Normal file
28
internal/hub/systems/system_wifi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
82
internal/hub/systems/ws_data_timeout_test.go
Normal file
82
internal/hub/systems/ws_data_timeout_test.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
43
internal/hub/transport/wifi_test.go
Normal file
43
internal/hub/transport/wifi_test.go
Normal 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
24
internal/migrations/1790193183_network_monitor_cert.go
Normal file
24
internal/migrations/1790193183_network_monitor_cert.go
Normal 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)
|
||||
})
|
||||
}
|
||||
24
internal/migrations/1790273174_network_monitor_server.go
Normal file
24
internal/migrations/1790273174_network_monitor_server.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
25
internal/records/records_wifi_test.go
Normal file
25
internal/records/records_wifi_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 === ".") {
|
||||
|
||||
37
internal/site/src/lib/wifi.test.ts
Normal file
37
internal/site/src/lib/wifi.test.ts
Normal 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)
|
||||
})
|
||||
34
internal/site/src/lib/wifi.ts
Normal file
34
internal/site/src/lib/wifi.ts
Normal 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%)`
|
||||
}
|
||||
@@ -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 "الأعمدة الظاهرة"
|
||||
|
||||
@@ -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 "Видими полета"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "Ορατά πεδία"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user