mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-27 11:57:47 +02:00
Compare commits
39 Commits
dependabot
...
l10n_main_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f7b9f379 | ||
|
|
173b561f14 | ||
|
|
820e1d5de5 | ||
|
|
f2bf04f111 | ||
|
|
c32d962f9f | ||
|
|
f85b5ba3bf | ||
|
|
79ebf54bbc | ||
|
|
6fb22b7205 | ||
|
|
d9a7897dbe | ||
|
|
c7b72cc101 | ||
|
|
53fcd9bdff | ||
|
|
43ecac1155 | ||
|
|
b4c9e2237a | ||
|
|
c23a4d313f | ||
|
|
f99ba28b5e | ||
|
|
ebe0428445 | ||
|
|
36d864e797 | ||
|
|
bd295dbd49 | ||
|
|
960224aeca | ||
|
|
6265bc58c7 | ||
|
|
e99e0d8cfa | ||
|
|
7f187a895a | ||
|
|
45dde45e54 | ||
|
|
ab55501768 | ||
|
|
209fcd59f6 | ||
|
|
7c95bb1efd | ||
|
|
c1ea72b66c | ||
|
|
02278df9c7 | ||
|
|
a4eb214f26 | ||
|
|
afc8827744 | ||
|
|
6f781d37b2 | ||
|
|
12011d54f5 | ||
|
|
8602c82ff2 | ||
|
|
234d10ae7a | ||
|
|
a203315ae3 | ||
|
|
ac3f859ea5 | ||
|
|
a06fed4d2b | ||
|
|
2d1624e37a | ||
|
|
e7c27a2bd5 |
@@ -29,7 +29,6 @@ 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
|
||||
@@ -51,7 +50,6 @@ 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.
|
||||
@@ -157,8 +155,6 @@ 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 {
|
||||
@@ -223,10 +219,6 @@ 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 {
|
||||
@@ -260,11 +252,7 @@ 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
|
||||
err := a.connectionManager.Start(serverOptions)
|
||||
if err != nil {
|
||||
a.cleanupSensorShadow()
|
||||
}
|
||||
return err
|
||||
return a.connectionManager.Start(serverOptions)
|
||||
}
|
||||
|
||||
func (a *Agent) getFingerprint() string {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -21,10 +20,7 @@ import (
|
||||
// It handles both WebSocket and SSH connections, automatically switching between
|
||||
// them based on availability and managing reconnection attempts.
|
||||
type ConnectionManager struct {
|
||||
agent *Agent // Reference to the parent agent
|
||||
// mu guards State and isConnecting, which are read and written from both
|
||||
// the main event loop and the goroutine spawned by connect().
|
||||
mu sync.Mutex
|
||||
agent *Agent // Reference to the parent agent
|
||||
State ConnectionState // Current connection state
|
||||
eventChan chan ConnectionEvent // Channel for connection events
|
||||
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||
@@ -82,29 +78,6 @@ func (c *ConnectionManager) stopWsTicker() {
|
||||
}
|
||||
}
|
||||
|
||||
// getState returns the current connection state.
|
||||
func (c *ConnectionManager) getState() ConnectionState {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.State
|
||||
}
|
||||
|
||||
// setConnecting sets the isConnecting flag and reports its previous value.
|
||||
func (c *ConnectionManager) setConnecting(v bool) (previous bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
previous = c.isConnecting
|
||||
c.isConnecting = v
|
||||
return previous
|
||||
}
|
||||
|
||||
// isConnectingNow reports whether a reconnection attempt is currently in flight.
|
||||
func (c *ConnectionManager) isConnectingNow() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.isConnecting
|
||||
}
|
||||
|
||||
// Start begins connection attempts and enters the main event loop.
|
||||
// It handles connection events, periodic health updates, and graceful shutdown.
|
||||
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
@@ -149,10 +122,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
case connectionEvent := <-c.eventChan:
|
||||
c.handleEvent(connectionEvent)
|
||||
case <-c.wsTicker.C:
|
||||
// skip if connect() is still running its own attempt
|
||||
if !c.isConnectingNow() {
|
||||
_ = c.startWebSocketConnection()
|
||||
}
|
||||
_ = c.startWebSocketConnection()
|
||||
case <-healthTicker:
|
||||
_ = health.Update()
|
||||
case <-sigCtx.Done():
|
||||
@@ -185,7 +155,6 @@ func (c *ConnectionManager) stop() error {
|
||||
_ = c.agent.StopServer()
|
||||
c.agent.monitorManager.Stop()
|
||||
c.closeWebSocket()
|
||||
c.agent.cleanupSensorShadow()
|
||||
return health.CleanUp()
|
||||
}
|
||||
|
||||
@@ -195,15 +164,15 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||
case WebSocketConnect:
|
||||
c.handleStateChange(WebSocketConnected)
|
||||
case SSHConnect:
|
||||
if c.getState() == Disconnected {
|
||||
if c.State == Disconnected {
|
||||
c.handleStateChange(SSHConnected)
|
||||
}
|
||||
case WebSocketDisconnect:
|
||||
if c.getState() == WebSocketConnected {
|
||||
if c.State == WebSocketConnected {
|
||||
c.handleStateChange(Disconnected)
|
||||
}
|
||||
case SSHDisconnect:
|
||||
if c.getState() == SSHConnected {
|
||||
if c.State == SSHConnected {
|
||||
c.handleStateChange(Disconnected)
|
||||
}
|
||||
}
|
||||
@@ -212,40 +181,30 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||
// handleStateChange updates the connection state and performs necessary actions
|
||||
// based on the new state, including stopping services and initiating reconnections.
|
||||
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
||||
c.mu.Lock()
|
||||
if c.State == newState {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.State = newState
|
||||
c.mu.Unlock()
|
||||
|
||||
switch newState {
|
||||
case WebSocketConnected:
|
||||
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||
c.ConnectionType = system.ConnectionTypeWebSocket
|
||||
c.stopWsTicker()
|
||||
_ = c.agent.StopServer()
|
||||
c.setConnecting(false)
|
||||
c.isConnecting = false
|
||||
case SSHConnected:
|
||||
// stop new ws connection attempts
|
||||
slog.Info("SSH connection established")
|
||||
c.ConnectionType = system.ConnectionTypeSSH
|
||||
c.stopWsTicker()
|
||||
c.setConnecting(false)
|
||||
c.isConnecting = false
|
||||
case Disconnected:
|
||||
c.ConnectionType = system.ConnectionTypeNone
|
||||
// Always keep the ticker running while disconnected. A pending WebSocket
|
||||
// handshake started by connect() can fail asynchronously (e.g. the hub
|
||||
// closes the socket, or the deadline set in OnOpen expires) after
|
||||
// connect() has already returned with a nil error, in which case the
|
||||
// ticker would otherwise never get re-armed and the agent would stop
|
||||
// retrying entirely (#2326).
|
||||
c.startWsTicker()
|
||||
if c.setConnecting(true) {
|
||||
if c.isConnecting {
|
||||
// Already handling reconnection, avoid duplicate attempts
|
||||
return
|
||||
}
|
||||
c.isConnecting = true
|
||||
slog.Warn("Disconnected from hub")
|
||||
// make sure old ws connection is closed
|
||||
c.closeWebSocket()
|
||||
@@ -257,8 +216,10 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
||||
// connect handles the connection logic with proper delays and priority.
|
||||
// It attempts WebSocket connection first, falling back to SSH server if needed.
|
||||
func (c *ConnectionManager) connect() {
|
||||
c.setConnecting(true)
|
||||
defer c.setConnecting(false)
|
||||
c.isConnecting = true
|
||||
defer func() {
|
||||
c.isConnecting = false
|
||||
}()
|
||||
|
||||
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
|
||||
time.Sleep(5 * time.Second)
|
||||
@@ -272,7 +233,7 @@ func (c *ConnectionManager) connect() {
|
||||
_ = c.stop()
|
||||
os.Exit(1)
|
||||
}
|
||||
if c.getState() == Disconnected {
|
||||
if c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
}
|
||||
@@ -281,7 +242,7 @@ func (c *ConnectionManager) connect() {
|
||||
|
||||
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
||||
func (c *ConnectionManager) startWebSocketConnection() error {
|
||||
if c.getState() != Disconnected {
|
||||
if c.State != Disconnected {
|
||||
return errors.New("already connected")
|
||||
}
|
||||
if c.wsClient == nil {
|
||||
@@ -301,7 +262,7 @@ func (c *ConnectionManager) startWebSocketConnection() error {
|
||||
|
||||
// startSSHServer starts the SSH server if the agent is currently disconnected.
|
||||
func (c *ConnectionManager) startSSHServer() {
|
||||
if c.getState() == Disconnected {
|
||||
if c.State == Disconnected {
|
||||
go c.agent.StartServer(c.serverOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -78,10 +77,6 @@ func TestConnectionManager_StateTransitions(t *testing.T) {
|
||||
cm.handleStateChange(SSHConnected)
|
||||
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
|
||||
|
||||
// Prevent handleStateChange from spawning its async reconnect goroutine:
|
||||
// this test only checks the synchronous state machine, and the goroutine
|
||||
// would otherwise race with the direct field writes below.
|
||||
cm.setConnecting(true)
|
||||
cm.handleStateChange(Disconnected)
|
||||
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
|
||||
|
||||
@@ -100,6 +95,7 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
||||
Host: "localhost:8080",
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
initialState ConnectionState
|
||||
@@ -152,11 +148,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Prevent handleStateChange from spawning its async reconnect
|
||||
// goroutine: this test only checks the synchronous state machine,
|
||||
// and the goroutine would otherwise race with the direct field
|
||||
// writes here and in later subtests.
|
||||
cm.setConnecting(true)
|
||||
cm.State = tc.initialState
|
||||
cm.handleEvent(tc.event)
|
||||
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
|
||||
@@ -230,56 +221,12 @@ func TestConnectionManager_ReconnectionLogic(t *testing.T) {
|
||||
// Test that isConnecting flag prevents duplicate reconnection attempts
|
||||
// Start from connected state, then simulate disconnect
|
||||
cm.State = WebSocketConnected
|
||||
cm.setConnecting(false)
|
||||
cm.isConnecting = false
|
||||
|
||||
// First disconnect should trigger reconnection logic
|
||||
cm.handleStateChange(Disconnected)
|
||||
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
|
||||
assert.True(t, cm.isConnectingNow(), "Should set isConnecting flag")
|
||||
}
|
||||
|
||||
// TestConnectionManager_TickerSurvivesStaleDisconnect reproduces the freeze from
|
||||
// https://github.com/henrygd/beszel/issues/2326: a reconnect attempt's handshake
|
||||
// can fail asynchronously (after connect() already returned with a nil error)
|
||||
// while the manager is still in the Disconnected state. Previously the ticker
|
||||
// was only re-armed from connect()'s synchronous error branch, so once that
|
||||
// window was missed, the agent stopped retrying forever. The ticker must keep
|
||||
// running any time the manager transitions into Disconnected, regardless of
|
||||
// what happens to the in-flight handshake afterwards.
|
||||
func TestConnectionManager_TickerSurvivesStaleDisconnect(t *testing.T) {
|
||||
agent := createTestAgent(t)
|
||||
cm := agent.connectionManager
|
||||
cm.eventChan = make(chan ConnectionEvent, 1)
|
||||
|
||||
// Run on synctest's fake clock so the ticker fires without waiting a real
|
||||
// wsTickerInterval. The ticker must be created inside the bubble.
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
// Simulate a healthy WebSocket connection, then a disconnect - mirroring
|
||||
// handleStateChange's own Disconnected branch, but without launching the
|
||||
// real async connect() goroutine so the ticker state can be asserted
|
||||
// deterministically.
|
||||
cm.State = WebSocketConnected
|
||||
cm.stopWsTicker()
|
||||
cm.setConnecting(true)
|
||||
cm.handleStateChange(Disconnected)
|
||||
require.NotNil(t, cm.wsTicker, "ticker must be armed as soon as the manager becomes Disconnected")
|
||||
defer cm.stopWsTicker()
|
||||
|
||||
// Now simulate connect()'s in-flight handshake dying asynchronously with the
|
||||
// manager still Disconnected (e.g. a late OnClose on an unauthenticated
|
||||
// connection). This event is dropped by handleEvent since State is not
|
||||
// WebSocketConnected, but the ticker armed above must still be running so
|
||||
// the manager keeps retrying.
|
||||
cm.setConnecting(false)
|
||||
cm.handleEvent(WebSocketDisconnect)
|
||||
assert.Equal(t, Disconnected, cm.State)
|
||||
|
||||
select {
|
||||
case <-cm.wsTicker.C:
|
||||
case <-time.After(wsTickerInterval + 2*time.Second):
|
||||
t.Fatal("ticker did not fire after a stale disconnect event - agent would freeze forever")
|
||||
}
|
||||
})
|
||||
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
|
||||
}
|
||||
|
||||
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
|
||||
|
||||
122
agent/disk.go
122
agent/disk.go
@@ -3,7 +3,6 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
@@ -154,12 +153,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
|
||||
}
|
||||
|
||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||
// 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. 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, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
||||
if !ok {
|
||||
return false
|
||||
return
|
||||
}
|
||||
d.agent.fsStats[key] = fsStats
|
||||
name := key
|
||||
@@ -167,7 +166,6 @@ 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
|
||||
@@ -205,24 +203,14 @@ 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 {
|
||||
// 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)
|
||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
||||
if !match {
|
||||
return false
|
||||
}
|
||||
// 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, "")
|
||||
// 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, "")
|
||||
return true
|
||||
}
|
||||
|
||||
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
||||
@@ -538,43 +526,13 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
|
||||
|
||||
// normalizeDeviceName canonicalizes device strings for comparisons.
|
||||
func normalizeDeviceName(value string) string {
|
||||
name := strings.TrimSpace(value)
|
||||
if volume, ok := windowsVolumeName(name); ok {
|
||||
return volume
|
||||
}
|
||||
name = filepath.Base(name)
|
||||
name := filepath.Base(strings.TrimSpace(value))
|
||||
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]
|
||||
@@ -596,9 +554,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)
|
||||
}
|
||||
@@ -681,9 +639,19 @@ 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 the latest counters of any interval, else seed from current
|
||||
prev, hasPrev = a.diskBaseline[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() {
|
||||
prev = prevDiskFromCounter(d, now)
|
||||
}
|
||||
}
|
||||
@@ -718,31 +686,29 @@ 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)
|
||||
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)
|
||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
||||
|
||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
|
||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
||||
|
||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
||||
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
|
||||
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
|
||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
||||
|
||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||
// Equivalent to r_await and w_await in iostat.
|
||||
var rAwait, wAwait float64
|
||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
|
||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
||||
}
|
||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
|
||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
||||
}
|
||||
|
||||
// Update the baseline that seeds new intervals
|
||||
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
|
||||
// Update global fsStats baseline for cross-interval correctness
|
||||
stats.Time = now
|
||||
stats.TotalRead = d.ReadBytes
|
||||
stats.TotalWrite = d.WriteBytes
|
||||
stats.DiskReadPs = readMbPerSecond
|
||||
@@ -774,30 +740,6 @@ 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 /
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
//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,9 +3,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1032,10 +1030,8 @@ 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.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())
|
||||
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
||||
|
||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
||||
@@ -1045,114 +1041,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type fanSensor struct {
|
||||
key, path, chip string
|
||||
key, path string
|
||||
}
|
||||
|
||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
||||
@@ -34,10 +34,6 @@ 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
|
||||
@@ -104,7 +100,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
||||
if label != "" {
|
||||
key = chipName + "_" + label
|
||||
}
|
||||
sensors = append(sensors, fanSensor{key, inputPath, chipName})
|
||||
sensors = append(sensors, fanSensor{key, inputPath})
|
||||
}
|
||||
}
|
||||
return sensors, nil
|
||||
@@ -119,15 +115,3 @@ 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,20 +103,3 @@ 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)
|
||||
}
|
||||
|
||||
31
agent/gpu.go
31
agent/gpu.go
@@ -454,8 +454,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
||||
// It only reports capability presence and does not apply policy decisions.
|
||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||
caps := gpuCapabilities{
|
||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||
hasXe: gm.hasXe(),
|
||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||
hasXe: gm.hasXe(),
|
||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||
}
|
||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||
@@ -750,36 +750,9 @@ 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
|
||||
|
||||
@@ -28,7 +28,7 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
|
||||
case "http":
|
||||
return monitorHTTP(ctx, client, config.Target)
|
||||
case "dns":
|
||||
return monitorDNS(ctx, config.Target, config.Server)
|
||||
return monitorDNS(ctx, config.Target)
|
||||
default:
|
||||
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
||||
}
|
||||
@@ -73,43 +73,19 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
|
||||
return -1, err
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
|
||||
func monitorDNS(ctx context.Context, target 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 := resolver.LookupHost(ctx, target)
|
||||
ips, err := net.DefaultResolver.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 {
|
||||
|
||||
@@ -376,79 +376,15 @@ 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(), "", "")
|
||||
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")
|
||||
responseUs, err := monitorDNS(context.Background(), "")
|
||||
assert.Equal(t, int64(-1), responseUs)
|
||||
require.Error(t, err)
|
||||
})
|
||||
@@ -543,7 +479,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.")
|
||||
}
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
//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,9 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -34,8 +32,6 @@ type SensorConfig struct {
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
skipCollection bool
|
||||
skipGPU bool
|
||||
sensorShadow string
|
||||
firstRun bool
|
||||
}
|
||||
|
||||
@@ -45,14 +41,13 @@ 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, skipGPU == "true")
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
||||
}
|
||||
|
||||
// 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, skipGPU bool) *SensorConfig {
|
||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
||||
timeout := 2 * time.Second
|
||||
if sensorsTimeout != "" {
|
||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||
@@ -67,7 +62,6 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
|
||||
primarySensor: primarySensor,
|
||||
timeout: timeout,
|
||||
skipCollection: skipCollection,
|
||||
skipGPU: skipGPU,
|
||||
firstRun: true,
|
||||
sensors: make(map[string]struct{}),
|
||||
}
|
||||
@@ -79,19 +73,6 @@ 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, "-") {
|
||||
@@ -168,9 +149,6 @@ 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 "":
|
||||
@@ -267,102 +245,3 @@ 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,8 +5,6 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -330,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
||||
|
||||
// Check primary sensor
|
||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||
@@ -622,143 +620,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -268,14 +267,6 @@ 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
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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]
|
||||
@@ -1,101 +0,0 @@
|
||||
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])
|
||||
@@ -1,130 +0,0 @@
|
||||
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])
|
||||
@@ -1,111 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -1,54 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
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)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
//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")
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//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 }
|
||||
@@ -1,99 +0,0 @@
|
||||
//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
|
||||
}
|
||||
7
go.mod
7
go.mod
@@ -6,13 +6,10 @@ require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/distribution/reference v0.6.0
|
||||
github.com/ebitengine/purego v0.11.1
|
||||
github.com/ebitengine/purego v0.11.0
|
||||
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
|
||||
@@ -46,7 +43,6 @@ 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
|
||||
@@ -54,7 +50,6 @@ 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
|
||||
|
||||
12
go.sum
12
go.sum
@@ -21,8 +21,8 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
||||
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
@@ -83,14 +83,6 @@ 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=
|
||||
|
||||
@@ -25,9 +25,6 @@ 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.
|
||||
|
||||
@@ -11,14 +11,6 @@ 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:"-"`
|
||||
@@ -63,7 +55,6 @@ 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
|
||||
|
||||
}
|
||||
|
||||
@@ -119,17 +110,18 @@ type GPUData struct {
|
||||
}
|
||||
|
||||
type FsStats struct {
|
||||
Root bool `json:"-"`
|
||||
Mountpoint string `json:"-"`
|
||||
Name string `json:"-"`
|
||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
|
||||
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
|
||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||
Time time.Time `json:"-"`
|
||||
Root bool `json:"-"`
|
||||
Mountpoint string `json:"-"`
|
||||
Name string `json:"-"`
|
||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
|
||||
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
|
||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||
@@ -192,8 +184,6 @@ 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
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,10 +17,6 @@ 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...)
|
||||
}
|
||||
|
||||
@@ -57,15 +53,10 @@ 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 protocol != "tcp" {
|
||||
if e.Record.GetString("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)
|
||||
@@ -112,7 +103,6 @@ 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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +127,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", "server", "interval", "enabled"}
|
||||
fields := []string{"system", "target", "protocol", "port", "interval", "enabled"}
|
||||
for _, field := range fields {
|
||||
newRecord.Set(field, oldRecord.Get(field))
|
||||
}
|
||||
|
||||
@@ -174,39 +174,6 @@ 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 {
|
||||
@@ -232,7 +199,6 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
||||
"target": "https://example.com",
|
||||
"protocol": "http",
|
||||
"port": 443,
|
||||
"server": "1.1.1.1",
|
||||
"interval": 60,
|
||||
"enabled": true,
|
||||
"res": 1200,
|
||||
@@ -250,7 +216,6 @@ 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"))
|
||||
|
||||
@@ -28,32 +28,6 @@ func modifyIndexHTML(hub *Hub, html []byte) string {
|
||||
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
|
||||
}
|
||||
|
||||
// isAppRoute reports whether urlPath matches a frontend route, so unknown paths
|
||||
// can be served with a 404 status. The base path prefix is optional because
|
||||
// reverse proxies may or may not strip it before forwarding.
|
||||
//
|
||||
// Keep in sync with routes in internal/site/src/components/router.tsx.
|
||||
func isAppRoute(urlPath, basePath string) bool {
|
||||
urlPath = strings.ToLower(urlPath)
|
||||
if base := strings.TrimSuffix(strings.ToLower(basePath), "/"); base != "" {
|
||||
if rest, ok := strings.CutPrefix(urlPath, base); ok && (rest == "" || rest[0] == '/') {
|
||||
urlPath = rest
|
||||
}
|
||||
}
|
||||
urlPath = strings.TrimSuffix(urlPath, "/")
|
||||
switch urlPath {
|
||||
case "", "/containers", "/smart", "/monitors", "/settings", "/forgot-password", "/request-otp":
|
||||
return true
|
||||
}
|
||||
// routes with a single required (/system/:id) or optional (/settings/:name?) param
|
||||
for _, prefix := range [...]string{"/system/", "/settings/"} {
|
||||
if param, ok := strings.CutPrefix(urlPath, prefix); ok {
|
||||
return param != "" && !strings.Contains(param, "/")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getPublicAppInfo(hub *Hub) PublicAppInfo {
|
||||
parsedURL, _ := url.Parse(hub.appURL)
|
||||
info := PublicAppInfo{
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
html := modifyIndexHTML(h, indexFile)
|
||||
basePath := getPublicAppInfo(h).BASE_PATH
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
@@ -37,13 +36,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
e.Response.Header().Del("X-Frame-Options")
|
||||
e.Response.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
// still serve the app for unknown paths (it renders a 404 page),
|
||||
// but with a 404 status so scanners and fail2ban see the miss
|
||||
status := http.StatusOK
|
||||
if !isAppRoute(e.Request.URL.Path, basePath) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
return e.HTML(status, html)
|
||||
return e.HTML(http.StatusOK, html)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package hub
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsAppRoute(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
basePath string
|
||||
want bool
|
||||
}{
|
||||
// known routes
|
||||
{"/", "/", true},
|
||||
{"/containers", "/", true},
|
||||
{"/containers/", "/", true},
|
||||
{"/Containers", "/", true},
|
||||
{"/smart", "/", true},
|
||||
{"/monitors", "/", true},
|
||||
{"/forgot-password", "/", true},
|
||||
{"/request-otp", "/", true},
|
||||
{"/system/abc123", "/", true},
|
||||
{"/system/abc123/", "/", true},
|
||||
{"/settings", "/", true},
|
||||
{"/settings/general", "/", true},
|
||||
|
||||
// unknown paths
|
||||
{"/.env", "/", false},
|
||||
{"/phpinfo.php", "/", false},
|
||||
{"/wp-admin/", "/", false},
|
||||
{"/.git/config", "/", false},
|
||||
{"/system", "/", false},
|
||||
{"/system/", "/", false},
|
||||
{"/system/abc/def", "/", false},
|
||||
{"/settings/general/extra", "/", false},
|
||||
{"/containersx", "/", false},
|
||||
|
||||
// base path, prefix not stripped by proxy
|
||||
{"/beszel", "/beszel/", true},
|
||||
{"/beszel/", "/beszel/", true},
|
||||
{"/beszel/containers", "/beszel/", true},
|
||||
{"/beszel/system/abc123", "/beszel/", true},
|
||||
{"/beszel/.env", "/beszel/", false},
|
||||
{"/beszelx", "/beszel/", false},
|
||||
|
||||
// base path, prefix stripped by proxy
|
||||
{"/", "/beszel/", true},
|
||||
{"/containers", "/beszel/", true},
|
||||
{"/.env", "/beszel/", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, isAppRoute(tt.path, tt.basePath), "path=%q basePath=%q", tt.path, tt.basePath)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,6 @@ func (sys *System) update() error {
|
||||
|
||||
// ensure deprecated fields from older agents are migrated to current fields
|
||||
migrateDeprecatedFields(data, !sys.detailsFetched.Load())
|
||||
sys.data = data
|
||||
|
||||
// create system records
|
||||
_, err = sys.createRecords(data)
|
||||
@@ -280,10 +279,6 @@ 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 {
|
||||
@@ -705,9 +700,11 @@ func (sys *System) ensureSSHTransport() error {
|
||||
}
|
||||
|
||||
// fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available.
|
||||
// Each fetch decodes into a new struct: CBOR leaves fields the agent omits
|
||||
// untouched, and real-time and regular updates may fetch concurrently.
|
||||
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||
if sys.data == nil {
|
||||
sys.data = &system.CombinedData{}
|
||||
}
|
||||
|
||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||
wsData, err := sys.fetchDataViaWebSocket(options)
|
||||
if err == nil {
|
||||
@@ -743,11 +740,11 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wsDataRequestTimeout)
|
||||
defer cancel()
|
||||
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
||||
data := &system.CombinedData{}
|
||||
if err := wsTransport.Request(ctx, common.GetData, options, data); err != nil {
|
||||
err := wsTransport.Request(ctx, common.GetData, options, sys.data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
return sys.data, nil
|
||||
}
|
||||
|
||||
// FetchContainerInfoFromAgent fetches container info from the agent
|
||||
@@ -809,8 +806,9 @@ func MakeStableHashId(strings ...string) string {
|
||||
}
|
||||
|
||||
// fetchDataViaSSH handles fetching data using SSH.
|
||||
// This function encapsulates the original SSH logic.
|
||||
// It updates sys.data directly upon successful fetch.
|
||||
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||
data := &system.CombinedData{}
|
||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
@@ -821,8 +819,7 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
||||
return false, err
|
||||
}
|
||||
|
||||
// reset in case of retry after a partial decode
|
||||
*data = system.CombinedData{}
|
||||
*sys.data = system.CombinedData{}
|
||||
|
||||
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||
@@ -831,7 +828,7 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
||||
|
||||
var resp common.AgentResponse
|
||||
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
||||
*data = *resp.SystemData
|
||||
*sys.data = *resp.SystemData
|
||||
if err := session.Wait(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -841,9 +838,9 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
||||
|
||||
var decodeErr error
|
||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||
decodeErr = cbor.NewDecoder(stdout).Decode(data)
|
||||
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||
} else {
|
||||
decodeErr = json.NewDecoder(stdout).Decode(data)
|
||||
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
|
||||
}
|
||||
|
||||
if decodeErr != nil {
|
||||
@@ -860,7 +857,7 @@ func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.C
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
return sys.data, nil
|
||||
}
|
||||
|
||||
// runSSHOperation establishes an SSH session and executes the provided operation.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package systems
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func 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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//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")
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
//go:build testing
|
||||
|
||||
package systems
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// sequenceDataClient answers each GetData request with the next queued payload.
|
||||
type sequenceDataClient struct {
|
||||
gws.BuiltinEventHandler
|
||||
responses chan esystem.CombinedData
|
||||
}
|
||||
|
||||
func (c *sequenceDataClient) 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
|
||||
}
|
||||
data, _ := cbor.Marshal(<-c.responses)
|
||||
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, Data: data})
|
||||
_ = conn.WriteMessage(gws.OpcodeBinary, response)
|
||||
}
|
||||
|
||||
// Fields the agent omits must not carry over from a previous response.
|
||||
func TestFetchDataViaWebSocketDoesNotRetainOmittedFields(t *testing.T) {
|
||||
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 := &sequenceDataClient{responses: make(chan esystem.CombinedData, 2)}
|
||||
client.responses <- esystem.CombinedData{
|
||||
Details: &esystem.Details{Hostname: "host"},
|
||||
SystemdServicesUpdated: true,
|
||||
Stats: esystem.Stats{Batteries: map[string]uint8{"BAT0": 50, "BAT1": 60}},
|
||||
Info: esystem.Info{WiFi: map[string]esystem.WiFi{"wlan0": {SSID: "home"}}},
|
||||
}
|
||||
client.responses <- esystem.CombinedData{
|
||||
Stats: esystem.Stats{Batteries: map[string]uint8{"BAT0": 40}},
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
first, err := sys.fetchDataFromAgent(common.DataRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first.Details)
|
||||
require.Len(t, first.Stats.Batteries, 2)
|
||||
|
||||
second, err := sys.fetchDataFromAgent(common.DataRequestOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, second.Details)
|
||||
require.False(t, second.SystemdServicesUpdated)
|
||||
require.Equal(t, map[string]uint8{"BAT0": 40}, second.Stats.Batteries)
|
||||
require.Empty(t, second.Info.WiFi)
|
||||
|
||||
// the first result is not mutated by the second fetch
|
||||
require.Len(t, first.Stats.Batteries, 2)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("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,8 +267,6 @@ 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
|
||||
@@ -287,10 +285,6 @@ 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
|
||||
@@ -620,14 +614,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -258,7 +258,7 @@ function AdminDropdownGroup() {
|
||||
return (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={prependBasePath("/_/#/collections?collection=users")} target="_blank">
|
||||
<a href={prependBasePath("/_/")} target="_blank">
|
||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||
<span>
|
||||
<Trans>Users</Trans>
|
||||
|
||||
@@ -37,7 +37,6 @@ type MonitorValues = {
|
||||
target: string
|
||||
protocol: MonitorProtocol
|
||||
port: number
|
||||
server: string
|
||||
interval: string
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@ type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
|
||||
interval: number
|
||||
}
|
||||
|
||||
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval" | "server">
|
||||
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval">
|
||||
|
||||
const defaultInterval = 30
|
||||
|
||||
@@ -60,7 +59,6 @@ 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 => {
|
||||
@@ -80,8 +78,6 @@ 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,
|
||||
}
|
||||
}),
|
||||
@@ -104,7 +100,6 @@ 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) {
|
||||
@@ -157,19 +152,18 @@ function buildMonitorPayload(values: MonitorValues, enabled = true) {
|
||||
return payload
|
||||
}
|
||||
|
||||
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 : ""}`
|
||||
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port">
|
||||
function getMonitorIdentityKey({ system, target, protocol, port }: MonitorIdentity) {
|
||||
return `${system}${target}${protocol}${port}`
|
||||
}
|
||||
|
||||
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
|
||||
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", rawServer = ""] = line.split(",")
|
||||
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = ""] = 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"}`)
|
||||
@@ -182,7 +176,6 @@ 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}`,
|
||||
})
|
||||
}
|
||||
@@ -190,8 +183,7 @@ 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}`
|
||||
const server = monitor.protocol !== "dns" ? "" : monitor.server
|
||||
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval, server]).join(",")
|
||||
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval]).join(",")
|
||||
}
|
||||
|
||||
function SystemMultiSelect({
|
||||
@@ -506,7 +498,7 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
|
||||
<Trans>Bulk Add {{ foo: t`Network Monitors` }}</Trans>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
|
||||
<Trans>target[,protocol[,port[,interval]]]</Trans>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
|
||||
@@ -540,16 +532,11 @@ 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",
|
||||
"example.com,dns,,,1.1.1.1",
|
||||
].join("\n")}
|
||||
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60"].join("\n")}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
|
||||
<Trans>target[,protocol[,port[,interval]]]</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -604,7 +591,6 @@ 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 ?? "")
|
||||
@@ -623,7 +609,6 @@ 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())
|
||||
@@ -644,7 +629,6 @@ function MonitorDialogContent({
|
||||
target,
|
||||
protocol,
|
||||
port: protocol === "tcp" ? Number(port) : 0,
|
||||
server: protocol === "dns" ? server.trim() : "",
|
||||
interval: monitorInterval,
|
||||
},
|
||||
monitor ? monitor.enabled : true
|
||||
@@ -725,7 +709,7 @@ function MonitorDialogContent({
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder={protocol === "http" ? "http://localhost:8090" : protocol === "dns" ? "example.com" : "1.1.1.1"}
|
||||
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -761,21 +745,6 @@ 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>
|
||||
|
||||
@@ -31,8 +31,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import type { ReadableAtom } from "nanostores"
|
||||
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { SystemStatus } from "@/lib/enums"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
@@ -72,7 +71,6 @@ const isMuted = (record: NetworkMonitorRecord, systemRecord: SystemRecord | unde
|
||||
|
||||
export function getMonitorColumns(
|
||||
longestTarget = "",
|
||||
$longestSystemName: ReadableAtom<string>,
|
||||
{
|
||||
onEdit,
|
||||
onDelete,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
ArrowUpIcon,
|
||||
EthernetPortIcon,
|
||||
EyeIcon,
|
||||
GlobeIcon,
|
||||
LandmarkIcon,
|
||||
LoaderCircleIcon,
|
||||
ServerIcon,
|
||||
@@ -158,26 +157,6 @@ export default function NetworkMonitorsTableNew({
|
||||
return longestTarget
|
||||
}, [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) => {
|
||||
let batch = pb.createBatch()
|
||||
@@ -277,7 +256,7 @@ export default function NetworkMonitorsTableNew({
|
||||
)
|
||||
|
||||
const columns = useMemo(() => {
|
||||
let columns = getMonitorColumns(longestTarget, $longestSystemName, {
|
||||
let columns = getMonitorColumns(longestTarget, {
|
||||
onEdit: setEditingMonitor,
|
||||
onDelete: handleDeleteRequest,
|
||||
onSetEnabled: handleSetEnabled,
|
||||
@@ -285,7 +264,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, $longestSystemName])
|
||||
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget])
|
||||
|
||||
const table = useReactTable({
|
||||
data: monitors,
|
||||
@@ -741,13 +720,6 @@ 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>
|
||||
|
||||
@@ -15,7 +15,7 @@ const routes = {
|
||||
* The base path of the application.
|
||||
* This is used to prepend the base path to all routes.
|
||||
*/
|
||||
export const basePath = globalThis.BESZEL?.BASE_PATH || ""
|
||||
export const basePath = BESZEL?.BASE_PATH || ""
|
||||
|
||||
/**
|
||||
* Prepends the base path to the given path.
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -136,7 +135,6 @@ 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>
|
||||
@@ -222,7 +220,6 @@ 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 { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard } from "../chart-card"
|
||||
|
||||
@@ -79,6 +79,7 @@ export function GpuPowerChart({
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
className={cn(grid && "!col-span-1")}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`GPU Power Draw`}
|
||||
@@ -96,8 +97,8 @@ export function GpuPowerChart({
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 */
|
||||
/** 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 */
|
||||
export function GpuCharts({
|
||||
chartData,
|
||||
grid,
|
||||
@@ -113,86 +114,77 @@ 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 && (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{children}
|
||||
{hasGpuEnginesData && (
|
||||
<ChartCard
|
||||
legend={true}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`GPU Engines`}
|
||||
description={t`Average utilization of GPU engines`}
|
||||
>
|
||||
<GpuEnginesChart chartData={chartData} />
|
||||
</ChartCard>
|
||||
)}
|
||||
{Object.keys(lastGpus).map((id) => {
|
||||
const gpu = lastGpus[id] as GPUData
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<ChartCard
|
||||
legend={true}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`GPU Engines`}
|
||||
description={t`Average utilization of GPU engines`}
|
||||
title={`${gpu.n} ${t`Usage`}`}
|
||||
description={t`Average utilization of ${gpu.n}`}
|
||||
>
|
||||
<GpuEnginesChart chartData={chartData} />
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||
color: 1,
|
||||
opacity: 0.35,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</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}>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${gpu.n} ${t`Usage`}`}
|
||||
description={t`Average utilization of ${gpu.n}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
|
||||
color: 1,
|
||||
opacity: 0.35,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{(gpu.mt ?? 0) > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${gpu.n} VRAM`}
|
||||
description={t`Precise utilization at the recorded time`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||
color: 2,
|
||||
opacity: 0.25,
|
||||
},
|
||||
]}
|
||||
max={gpu.mt}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(gpu.mt ?? 0) > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${gpu.n} VRAM`}
|
||||
description={t`Precise utilization at the recorded time`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Usage`,
|
||||
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||
color: 2,
|
||||
opacity: 0.25,
|
||||
},
|
||||
]}
|
||||
max={gpu.mt}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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,7 +14,6 @@ import {
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageIcon,
|
||||
PauseCircleIcon,
|
||||
PenBoxIcon,
|
||||
PlayCircleIcon,
|
||||
@@ -38,8 +37,7 @@ import {
|
||||
secondsToUptimeString,
|
||||
} from "@/lib/utils"
|
||||
import { batteryStateTranslations } from "@/lib/i18n"
|
||||
import { connectedWiFi, strongestWiFi, strongestWiFiSignal, wifiSignalState } from "@/lib/wifi"
|
||||
import type { SystemRecord, WiFi } from "@/types"
|
||||
import type { SystemRecord } from "@/types"
|
||||
import { SystemDialog } from "../add-system"
|
||||
import AlertButton from "../alerts/alert-button"
|
||||
import { $router, Link } from "../router"
|
||||
@@ -82,15 +80,6 @@ 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
|
||||
}
|
||||
@@ -347,57 +336,6 @@ 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",
|
||||
@@ -455,45 +393,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
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]]: security > 0,
|
||||
[STATUS_COLORS[SystemStatus.Pending]]: security === 0 && total > 0,
|
||||
[STATUS_COLORS[SystemStatus.Up]]: total === 0,
|
||||
})}
|
||||
/>
|
||||
{total === 0 ? t`Up to date` : plural(total, { one: "# update", other: "# updates" })}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorFn: ({ info }) => info.u || undefined,
|
||||
id: "uptime",
|
||||
@@ -699,23 +598,6 @@ 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 (
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: TwemojiCountryFlags, Inter, InterVariable, sans-serif;
|
||||
--font-sans: Inter, InterVariable, sans-serif;
|
||||
|
||||
--breakpoint-xs: 26.6rem;
|
||||
--breakpoint-450: 28rem;
|
||||
@@ -118,25 +118,11 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
/* Fonts */
|
||||
|
||||
/* Twemoji Country Flags font from country-flag-emoji-polyfill,
|
||||
* derived from Twemoji Mozilla. Twemoji graphics are licensed CC BY 4.0:
|
||||
* https://creativecommons.org/licenses/by/4.0/
|
||||
*/
|
||||
@font-face {
|
||||
font-family: TwemojiCountryFlags;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("./assets/TwemojiCountryFlags.woff2") format("woff2");
|
||||
/* Regional indicator symbols make up ISO 3166-1 flag emoji. */
|
||||
unicode-range: U+1F1E6-1F1FF;
|
||||
}
|
||||
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: TwemojiCountryFlags, Inter, InterVariable, sans-serif;
|
||||
font-family: Inter, InterVariable, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,17 +141,6 @@
|
||||
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;
|
||||
@@ -216,4 +191,4 @@
|
||||
|
||||
.recharts-yAxis {
|
||||
@apply tabular-nums;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import type { MonitorCertInfo, MonitorStats, NetworkMonitorRecord, RawMonitorStatsRecord } from "@/types"
|
||||
import { toFixedFloat } from "./utils"
|
||||
|
||||
/** Derive chart metrics from the counts and response sum stored at every retention tier. */
|
||||
export function getMonitorStats(record: RawMonitorStatsRecord): MonitorStats {
|
||||
return {
|
||||
res_avg: record.success_count > 0 ? toFixedFloat(record.res_sum / record.success_count, 2) : 0,
|
||||
res_avg: record.success_count > 0 ? record.res_sum / record.success_count : 0,
|
||||
res_min: record.res_min,
|
||||
res_max: record.res_max,
|
||||
loss:
|
||||
record.total_count > 0
|
||||
? toFixedFloat(((record.total_count - record.success_count) / record.total_count) * 100, 2)
|
||||
: 0,
|
||||
loss: record.total_count > 0 ? ((record.total_count - record.success_count) / record.total_count) * 100 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ async function fetchMonitorStats(
|
||||
}
|
||||
|
||||
const NETWORK_MONITOR_FIELDS =
|
||||
"id,system,target,protocol,port,server,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
|
||||
"id,system,target,protocol,port,interval,res,resMin1h,resMax1h,resAvg1h,loss1h,enabled,certInfo,updated"
|
||||
|
||||
interface UseNetworkMonitorsProps {
|
||||
systemId?: string
|
||||
|
||||
@@ -111,17 +111,18 @@ export const updateFavicon = (() => {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||
${downCount > 0 &&
|
||||
`
|
||||
${
|
||||
downCount > 0 &&
|
||||
`
|
||||
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
||||
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
||||
`
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
`
|
||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -198,7 +199,7 @@ export function decimalString(num: number, digits = 2) {
|
||||
return formatter.format(num)
|
||||
}
|
||||
|
||||
export function formatMicroseconds(microseconds: number, fixedDigits = true): string {
|
||||
export function formatMicroseconds(microseconds: number, showDigits = true): string {
|
||||
if (!Number.isFinite(microseconds)) {
|
||||
return "-"
|
||||
}
|
||||
@@ -207,17 +208,15 @@ export function formatMicroseconds(microseconds: number, fixedDigits = true): st
|
||||
return `${microseconds}μs`
|
||||
}
|
||||
|
||||
const digitFormatter = fixedDigits ? decimalString : toFixedFloat
|
||||
|
||||
if (microseconds < 1_000_000) {
|
||||
const milliseconds = microseconds / 1000
|
||||
const digits = milliseconds >= 10 ? 1 : 2
|
||||
return `${digitFormatter(milliseconds, digits)}ms`
|
||||
return `${decimalString(milliseconds, showDigits ? digits : 0)}ms`
|
||||
}
|
||||
|
||||
const seconds = microseconds / 1_000_000
|
||||
const digits = seconds >= 10 ? 1 : 2
|
||||
return `${digitFormatter(seconds, digits)}s`
|
||||
return `${decimalString(seconds, showDigits ? digits : 0)}s`
|
||||
}
|
||||
|
||||
/** Get value from local or session storage */
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
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-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\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"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "نعم"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: bg\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Bulgarian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Да"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Настройките за потребителя ти са обновени."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: cs\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Czech\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ano"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Vaše uživatelská nastavení byla aktualizována."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: da\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Danish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ja"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Dine brugerindstillinger er opdateret."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: de\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ja"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Deine Benutzereinstellungen wurden aktualisiert."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: el\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Greek\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ναι"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Οι ρυθμίσεις χρήστη σας ενημερώθηκαν."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: es\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:28\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Spanish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Sí"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Tu configuración de usuario ha sido actualizada."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fa\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Persian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "بله"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "تنظیمات کاربری شما بهروزرسانی شد."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: fr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-25 14:03\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} minute} other {{countString} minutes}
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "{daysLeft, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
msgstr "{daysLeft, plural, one {# jour} other {# jours}}"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "{diskName} I/O"
|
||||
@@ -62,7 +62,7 @@ msgstr "E/S {diskName}"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
|
||||
msgstr ""
|
||||
msgstr "{numFailed, plural, one {# service en échec} other {# services en échec}}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgid "{threads, plural, one {# thread} other {# threads}}"
|
||||
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# thread} other {# threads}}"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{totalCount, plural, one {# service} other {# services}}"
|
||||
msgstr ""
|
||||
msgstr "{totalCount, plural, one {# service} other {# services}}"
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "1 hour"
|
||||
@@ -137,7 +137,7 @@ msgstr "État actif"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
msgstr "Ajouter"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
@@ -406,15 +406,15 @@ msgstr "Celsius (°C)"
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Certificate"
|
||||
msgstr ""
|
||||
msgstr "Certificat"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expired {expires}"
|
||||
msgstr ""
|
||||
msgstr "Certificat expiré {expires}"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expires {expires}"
|
||||
msgstr ""
|
||||
msgstr "Le certificat expire le {expires}"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Change display units for metrics."
|
||||
@@ -905,7 +905,7 @@ msgstr "Sorti actif"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Expired"
|
||||
msgstr ""
|
||||
msgstr "Expiré"
|
||||
|
||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||
msgid "Expires after one hour or on hub restart."
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Oui"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Vos paramètres utilisateur ont été mis à jour."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: he\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Hebrew\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n==2 ? 1 : 2);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "כן"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "הגדרות המשתמש שלך עודכנו."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: hr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Croatian\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Da"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Vaše korisničke postavke su ažurirane."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: hu\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Hungarian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Igen"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "A felhasználói beállítások frissítésre kerültek."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: id\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Indonesian\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ya"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Pengaturan pengguna anda telah diperbarui."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: it\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Italian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Sì"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Le impostazioni utente sono state aggiornate."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ja\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Japanese\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "はい"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "ユーザー設定が更新されました。"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ko\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Korean\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "예"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "사용자 설정이 업데이트되었습니다."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: nl\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ja"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Je gebruikersinstellingen zijn bijgewerkt."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: no\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 18:40\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Norwegian\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} minutt} other {{countString} minutter
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "{daysLeft, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
msgstr "{daysLeft, plural, one {# dag} other {# dager}}"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "{diskName} I/O"
|
||||
@@ -62,7 +62,7 @@ msgstr "I/O for {diskName}"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
|
||||
msgstr ""
|
||||
msgstr "{numFailed, plural, one {# feilet tjeneste} other {# feilede tjenester}}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgid "{threads, plural, one {# thread} other {# threads}}"
|
||||
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# tråd} other {# tråder}}"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{totalCount, plural, one {# service} other {# services}}"
|
||||
msgstr ""
|
||||
msgstr "{totalCount, plural, one {# tjeneste} other {# tjenester}}"
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "1 hour"
|
||||
@@ -137,7 +137,7 @@ msgstr "Aktiv tilstand"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
msgstr "Legg til"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
@@ -406,15 +406,15 @@ msgstr "Celsius (°C)"
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Certificate"
|
||||
msgstr ""
|
||||
msgstr "Sertifikat"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expired {expires}"
|
||||
msgstr ""
|
||||
msgstr "Sertifikatet utløper {expires}"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expires {expires}"
|
||||
msgstr ""
|
||||
msgstr "Sertifikatet utløper {expires}"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Change display units for metrics."
|
||||
@@ -905,7 +905,7 @@ msgstr "Avsluttet aktiv"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Expired"
|
||||
msgstr ""
|
||||
msgstr "Utløpt"
|
||||
|
||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||
msgid "Expires after one hour or on hub restart."
|
||||
@@ -1977,7 +1977,7 @@ 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 ""
|
||||
msgstr "mål [,protokoll[,port[,interval]]"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ja"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Dine brukerinnstillinger har blitt oppdatert."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pl\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Polish\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Tak"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Twoje ustawienia użytkownika zostały zaktualizowane."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: pt\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Portuguese\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Sim"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "As configurações do seu usuário foram atualizadas."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ro\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 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Romanian\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n"
|
||||
@@ -52,14 +52,26 @@ msgstr ""
|
||||
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||
msgstr ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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 oră"
|
||||
@@ -123,10 +135,10 @@ msgstr "Alerte Active"
|
||||
msgid "Active state"
|
||||
msgstr "Stare activă"
|
||||
|
||||
#: 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 "Stare activă"
|
||||
msgid "Add {foo}"
|
||||
msgstr "Adaugă {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 "Adaugă URL"
|
||||
@@ -260,6 +279,7 @@ msgstr ""
|
||||
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 ""
|
||||
@@ -383,6 +403,19 @@ msgstr "Atenție - posibilă pierdere de date"
|
||||
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 "Schimbă unitățile de afișare pentru metrici."
|
||||
@@ -776,12 +809,15 @@ msgstr "Durată"
|
||||
msgid "Edit"
|
||||
msgstr "Editează"
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Edit {foo}"
|
||||
msgstr "Editează {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 "Inactiv"
|
||||
|
||||
#: 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 ""
|
||||
@@ -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 ""
|
||||
@@ -1234,6 +1273,7 @@ msgstr "Memorie Utilizată"
|
||||
msgid "Memory usage of containers"
|
||||
msgstr "Utilizarea memoriei de către containere"
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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 "Detalii S.M.A.R.T."
|
||||
msgid "S.M.A.R.T. Self-Test"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Save {foo}"
|
||||
msgstr ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 "Sarcini"
|
||||
@@ -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 ""
|
||||
|
||||
#: 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: ru\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-26 03:21\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Russian\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
@@ -54,7 +54,7 @@ msgstr "{count, plural, one {{countString} минута} few {{countString} ми
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "{daysLeft, plural, one {# day} other {# days}}"
|
||||
msgstr ""
|
||||
msgstr "{daysLeft, plural, one {# день} other {# дней}}"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "{diskName} I/O"
|
||||
@@ -62,7 +62,7 @@ msgstr "Ввод-вывод {diskName}"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{numFailed, plural, one {# failed service} other {# failed services}}"
|
||||
msgstr ""
|
||||
msgstr "{numFailed, plural, one {# сбой службы} other {# сбоев служб}}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgid "{threads, plural, one {# thread} other {# threads}}"
|
||||
@@ -70,7 +70,7 @@ msgstr "{threads, plural, one {# поток} few {# потока} many {# пот
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{totalCount, plural, one {# service} other {# services}}"
|
||||
msgstr ""
|
||||
msgstr "{totalCount, plural, one {# сервис} other {# сервисов}}"
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "1 hour"
|
||||
@@ -137,7 +137,7 @@ msgstr "Активное состояние"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Add"
|
||||
msgstr ""
|
||||
msgstr "Добавить"
|
||||
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
@@ -406,15 +406,15 @@ msgstr "Цельсий (°C)"
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Certificate"
|
||||
msgstr ""
|
||||
msgstr "Сертификат"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expired {expires}"
|
||||
msgstr ""
|
||||
msgstr "Сертификат истёк {expires}"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-table.tsx
|
||||
msgid "Certificate expires {expires}"
|
||||
msgstr ""
|
||||
msgstr "Сертификат истекает {expires}"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Change display units for metrics."
|
||||
@@ -905,7 +905,7 @@ msgstr "Завершился активным"
|
||||
|
||||
#: src/components/network-monitors-table/network-monitors-columns.tsx
|
||||
msgid "Expired"
|
||||
msgstr ""
|
||||
msgstr "Срок истек"
|
||||
|
||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||
msgid "Expires after one hour or on hub restart."
|
||||
@@ -1977,7 +1977,7 @@ msgstr "Цель"
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "target[,protocol[,port[,interval]]]"
|
||||
msgstr ""
|
||||
msgstr "target[,protocol[,port[,интервал]]]"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Да"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Ваши настройки пользователя были обновлены."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: sl\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Slovenian\n"
|
||||
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Da"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Vaše uporabniške nastavitve so posodobljene."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: sr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Serbian (Cyrillic)\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Да"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Ваша корисничка подешавања су ажурирана."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: sv\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Swedish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ja"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Dina användarinställningar har uppdaterats."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: th\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 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Thai\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -52,14 +52,26 @@ msgstr ""
|
||||
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||
msgstr ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{totalCount, plural, one {# service} other {# services}}"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "1 hour"
|
||||
msgstr ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
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 ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
msgid "S.M.A.R.T. Self-Test"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Save {foo}"
|
||||
msgstr ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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: tr\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Turkish\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Evet"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Kullanıcı ayarlarınız güncellendi."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ug\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 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Uyghur\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
@@ -52,14 +52,26 @@ msgstr ""
|
||||
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||
msgstr ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "{totalCount, plural, one {# service} other {# services}}"
|
||||
msgstr ""
|
||||
|
||||
#: src/lib/utils.ts
|
||||
msgid "1 hour"
|
||||
msgstr ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
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 ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
msgid "S.M.A.R.T. Self-Test"
|
||||
msgstr ""
|
||||
|
||||
#: src/components/add-system.tsx
|
||||
#: src/components/network-monitors-table/monitor-dialog.tsx
|
||||
msgid "Save {foo}"
|
||||
msgstr ""
|
||||
@@ -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 ""
|
||||
|
||||
#: 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 ""
|
||||
|
||||
#: 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: uk\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Ukrainian\n"
|
||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Так"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Ваші налаштування користувача були оновлені."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: uz\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:16\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Uzbek\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Ha"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Foydalanuvchi sozlamalaringiz yangilandi."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: vi\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-09-24 16:15\n"
|
||||
"PO-Revision-Date: 2026-09-24 17:29\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Vietnamese\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
@@ -2359,3 +2359,4 @@ msgstr "Có"
|
||||
#: src/components/routes/settings/layout.tsx
|
||||
msgid "Your user settings have been updated."
|
||||
msgstr "Cài đặt người dùng của bạn đã được cập nhật."
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user