mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-26 01:44:26 +00:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f2177b08 | ||
|
|
a042e19549 | ||
|
|
fe83f5b831 | ||
|
|
46fa7c581e | ||
|
|
24792aa24f | ||
|
|
16e3fbadce | ||
|
|
86ab0fae8b | ||
|
|
badd4c8245 | ||
|
|
7bea20e3b6 | ||
|
|
433b83800f | ||
|
|
3dfe062ee4 | ||
|
|
3fb97b800c | ||
|
|
d708def38f | ||
|
|
f50fb4f8e5 | ||
|
|
c25408651f | ||
|
|
d591da46f3 | ||
|
|
d80a2f49f9 | ||
|
|
21b648a005 | ||
|
|
151423ac63 | ||
|
|
f7528a0208 | ||
|
|
a20a7d2edc | ||
|
|
f2adb9cf94 | ||
|
|
4d10ea2e03 | ||
|
|
b5ef015451 | ||
|
|
6141b15f03 | ||
|
|
c21412f45d | ||
|
|
367d2f39da | ||
|
|
eabd9a950a | ||
|
|
0be9882b34 | ||
|
|
e4b84b72ab | ||
|
|
fc33e62736 | ||
|
|
a99fe5e997 | ||
|
|
0870716052 | ||
|
|
b1270e341c | ||
|
|
9042a8c5c8 | ||
|
|
8bf6917fe0 | ||
|
|
2d5ea3fa08 | ||
|
|
627d364071 | ||
|
|
8047f005d4 | ||
|
|
4a4610bbc3 | ||
|
|
1aaabfc255 | ||
|
|
97ea3c16cb | ||
|
|
c9de35fad2 | ||
|
|
a5f216f425 | ||
|
|
cbe4824ac3 | ||
|
|
2c69197d2d | ||
|
|
97e6f64bdc | ||
|
|
4a5915b141 | ||
|
|
e68372dce4 | ||
|
|
c52f3acb94 | ||
|
|
c09eb8c6df | ||
|
|
a0dc19eacf | ||
|
|
912bc50874 | ||
|
|
c54dbfba7c | ||
|
|
dd3f7d58b5 | ||
|
|
0509053a69 | ||
|
|
187dc886a9 | ||
|
|
2784460621 | ||
|
|
b0bc727411 | ||
|
|
6937453282 | ||
|
|
97de1471d7 | ||
|
|
bd7e359dcd | ||
|
|
d2352e8882 | ||
|
|
f7fd3ef403 | ||
|
|
db3afeabd9 | ||
|
|
b347599928 | ||
|
|
6e5440c21f | ||
|
|
bb1b39928e |
@@ -29,6 +29,7 @@ jobs:
|
|||||||
# henrygd/beszel-agent:alpine
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
|
flavor: latest=false
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
@@ -55,6 +56,7 @@ jobs:
|
|||||||
# henrygd/beszel-agent-nvidia:slim
|
# henrygd/beszel-agent-nvidia:slim
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||||
|
flavor: latest=false
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
@@ -123,6 +125,7 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||||
|
flavor: latest=false
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -150,6 +153,7 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent:alpine
|
# ghcr.io/henrygd/beszel-agent:alpine
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
|
flavor: latest=false
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
@@ -159,7 +163,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-alpine
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
# henrygd/beszel-agent
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
@@ -200,6 +204,8 @@ jobs:
|
|||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
|
# Variant images must not overwrite the standard image's latest tag.
|
||||||
|
flavor: ${{ matrix.flavor || 'latest=auto' }}
|
||||||
tags: ${{ matrix.tags }}
|
tags: ${{ matrix.tags }}
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
|
|||||||
+13
-1
@@ -29,6 +29,7 @@ type Agent struct {
|
|||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
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
|
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)
|
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
||||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
@@ -50,6 +51,7 @@ type Agent struct {
|
|||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
monitorManager *MonitorManager // Manages network monitors
|
monitorManager *MonitorManager // Manages network monitors
|
||||||
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
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.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -155,6 +157,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Debug("SMART", "err", err)
|
slog.Debug("SMART", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.packageUpdates = newPackageUpdatesManager(agent.dataDir)
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
agent.gpuManager, err = NewGPUManager()
|
agent.gpuManager, err = NewGPUManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,6 +223,10 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.packageUpdates != nil {
|
||||||
|
data.Info.PackageUpdates = a.packageUpdates.get(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
data.Info.ExtraFsPct = make(map[string]float64)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
@@ -252,7 +260,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
a.keys = serverOptions.Keys
|
a.keys = serverOptions.Keys
|
||||||
return a.connectionManager.Start(serverOptions)
|
err := a.connectionManager.Start(serverOptions)
|
||||||
|
if err != nil {
|
||||||
|
a.cleanupSensorShadow()
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func (c *ConnectionManager) stop() error {
|
|||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.agent.monitorManager.Stop()
|
c.agent.monitorManager.Stop()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
|
c.agent.cleanupSensorShadow()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+90
-32
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -153,12 +154,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||||
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
// key and reports whether it was added. The key selection itself lives in
|
||||||
// can stay directly unit-tested.
|
// registerFilesystemStats so that logic can stay directly unit-tested.
|
||||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
|
||||||
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
d.agent.fsStats[key] = fsStats
|
d.agent.fsStats[key] = fsStats
|
||||||
name := key
|
name := key
|
||||||
@@ -166,6 +167,7 @@ func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customNa
|
|||||||
name = customName
|
name = customName
|
||||||
}
|
}
|
||||||
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
||||||
@@ -203,14 +205,24 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
|||||||
// partition looks like the active root mount but still needs translating to an
|
// partition looks like the active root mount but still needs translating to an
|
||||||
// I/O device key.
|
// I/O device key.
|
||||||
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
||||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
// device is passed through as-is: findIoDevice normalizes it, and
|
||||||
|
// filepath.Base would turn a Windows volume name such as "C:" into "\"
|
||||||
|
// on the way in (#2417).
|
||||||
|
fs, match := findIoDevice(device, d.ctx.diskIoCounters)
|
||||||
if !match {
|
if !match {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// The resolved I/O device is already known here, so use it directly to avoid
|
// The root device is already resolved, so if it was registered earlier as an
|
||||||
// a second fallback search inside buildFsStatRegistration.
|
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
|
||||||
d.addFsStat(fs, mountpoint, true, "")
|
// entry rather than letting addLastResortRootFs guess a different device.
|
||||||
return true
|
if stats, exists := d.agent.fsStats[fs]; exists {
|
||||||
|
stats.Root = true
|
||||||
|
stats.Mountpoint = mountpoint
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Use the resolved I/O device directly to avoid a second fallback search
|
||||||
|
// inside registerFilesystemStats.
|
||||||
|
return d.addFsStat(fs, mountpoint, true, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
||||||
@@ -526,13 +538,43 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
|
|||||||
|
|
||||||
// normalizeDeviceName canonicalizes device strings for comparisons.
|
// normalizeDeviceName canonicalizes device strings for comparisons.
|
||||||
func normalizeDeviceName(value string) string {
|
func normalizeDeviceName(value string) string {
|
||||||
name := filepath.Base(strings.TrimSpace(value))
|
name := strings.TrimSpace(value)
|
||||||
|
if volume, ok := windowsVolumeName(name); ok {
|
||||||
|
return volume
|
||||||
|
}
|
||||||
|
name = filepath.Base(name)
|
||||||
if name == "." {
|
if name == "." {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return name
|
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.
|
// Sets start values for disk I/O stats.
|
||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
a.fsNames = a.fsNames[:0]
|
a.fsNames = a.fsNames[:0]
|
||||||
@@ -554,9 +596,9 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// populate initial values
|
// populate initial values
|
||||||
stats.Time = now
|
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
a.setDiskBaseline(device, prevDiskFromCounter(d, now))
|
||||||
// add to list of valid io device names
|
// add to list of valid io device names
|
||||||
a.fsNames = append(a.fsNames, device)
|
a.fsNames = append(a.fsNames, device)
|
||||||
}
|
}
|
||||||
@@ -639,19 +681,9 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// Previous snapshot for this interval and device
|
// Previous snapshot for this interval and device
|
||||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||||
if !hasPrev {
|
if !hasPrev {
|
||||||
// Seed from agent-level fsStats if present, else seed from current
|
// Seed from the latest counters of any interval, else seed from current
|
||||||
prev = prevDisk{
|
prev, hasPrev = a.diskBaseline[name]
|
||||||
readBytes: stats.TotalRead,
|
if !hasPrev {
|
||||||
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)
|
prev = prevDiskFromCounter(d, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,29 +718,31 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// This is the total number of milliseconds spent by all reads (as
|
// This is the total number of milliseconds spent by all reads (as
|
||||||
// measured from __make_request() to end_that_request_last()).
|
// measured from __make_request() to end_that_request_last()).
|
||||||
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
||||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
|
||||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
|
||||||
|
diskReadTime := utils.TwoDecimals(float64(deltaReadTime) / float64(msElapsed) * 100)
|
||||||
|
diskWriteTime := utils.TwoDecimals(float64(deltaWriteTime) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
// 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.
|
// 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).
|
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||||
// Equivalent to r_await and w_await in iostat.
|
// Equivalent to r_await and w_await in iostat.
|
||||||
var rAwait, wAwait float64
|
var rAwait, wAwait float64
|
||||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
|
||||||
}
|
}
|
||||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update global fsStats baseline for cross-interval correctness
|
// Update the baseline that seeds new intervals
|
||||||
stats.Time = now
|
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
stats.DiskReadPs = readMbPerSecond
|
stats.DiskReadPs = readMbPerSecond
|
||||||
@@ -740,6 +774,30 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setDiskBaseline stores the latest counters of a device. A cache interval
|
||||||
|
// without its own snapshot measures its first sample from them.
|
||||||
|
func (a *Agent) setDiskBaseline(name string, d prevDisk) {
|
||||||
|
if a.diskBaseline == nil {
|
||||||
|
a.diskBaseline = make(map[string]prevDisk)
|
||||||
|
}
|
||||||
|
a.diskBaseline[name] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ioTimeDelta returns the increase of a cumulative millisecond counter from
|
||||||
|
// the disk I/O stats. Linux prints these fields of /proc/diskstats as 32-bit
|
||||||
|
// unsigned ints, so they wrap to zero at 2^32. A busy disk reaches that in
|
||||||
|
// days for the weighted I/O time. Other platforms report 64-bit counters,
|
||||||
|
// so a lower value there is a reset.
|
||||||
|
func ioTimeDelta(current, previous uint64) uint64 {
|
||||||
|
if current >= previous {
|
||||||
|
return current - previous
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "linux" && previous <= math.MaxUint32 {
|
||||||
|
return current + (math.MaxUint32 + 1 - previous)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system.
|
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||||
// On Windows it returns the system drive (e.g. "C:").
|
// On Windows it returns the system drive (e.g. "C:").
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linux prints four millisecond fields of /proc/diskstats as 32-bit unsigned ints:
|
||||||
|
// read time, write time, io time and weighted io time. They wrap to zero at 2^32.
|
||||||
|
func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
|
||||||
|
const wrap = uint64(1) << 32
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
base uint64 // added to every previous time counter
|
||||||
|
}{
|
||||||
|
{"no wrap", 0},
|
||||||
|
{"32-bit wrap", wrap - 1000},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Deltas over 60s: read 300ms / 10 ops, write 400ms / 20 ops,
|
||||||
|
// io time 1200ms, weighted io 3000ms.
|
||||||
|
prev := prevDisk{
|
||||||
|
readBytes: 20000 * 512,
|
||||||
|
writeBytes: 10000 * 512,
|
||||||
|
readTime: tt.base + 900,
|
||||||
|
writeTime: tt.base + 700,
|
||||||
|
ioTime: tt.base + 400,
|
||||||
|
weightedIO: tt.base,
|
||||||
|
readCount: 1000,
|
||||||
|
writeCount: 500,
|
||||||
|
at: time.Now().Add(-60 * time.Second),
|
||||||
|
}
|
||||||
|
cur := func(v uint64) uint64 { return v % wrap }
|
||||||
|
line := fmt.Sprintf(" 8 0 sda %d 0 %d %d %d 0 %d %d 0 %d %d\n",
|
||||||
|
1010, 21200, cur(prev.readTime+300),
|
||||||
|
520, 10400, cur(prev.writeTime+400),
|
||||||
|
cur(prev.ioTime+1200), cur(prev.weightedIO+3000))
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
|
||||||
|
t.Setenv("HOST_PROC", dir)
|
||||||
|
t.Setenv("HOST_SYS", dir)
|
||||||
|
t.Setenv("HOST_DEV", dir)
|
||||||
|
t.Setenv("HOST_RUN", dir)
|
||||||
|
|
||||||
|
fs := &system.FsStats{Root: true}
|
||||||
|
a := &Agent{
|
||||||
|
fsNames: []string{"sda"},
|
||||||
|
fsStats: map[string]*system.FsStats{"sda": fs},
|
||||||
|
diskPrev: map[uint16]map[string]prevDisk{60000: {"sda": prev}},
|
||||||
|
}
|
||||||
|
var stats system.Stats
|
||||||
|
a.updateDiskIo(60000, &stats)
|
||||||
|
|
||||||
|
// Same order as DiskIoStats in system.FsStats.
|
||||||
|
want := [6]float64{0.5, 0.67, 2, 30, 20, 5}
|
||||||
|
for i := range want {
|
||||||
|
assert.InDelta(t, want[i], fs.DiskIoStats[i], 0.01, "DiskIoStats[%d]", i)
|
||||||
|
assert.InDelta(t, want[i], stats.DiskIoStats[i], 0.01, "system DiskIoStats[%d]", i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first sample of a cache interval has no snapshot of its own. It must
|
||||||
|
// measure the time counters from the same baseline as the byte counters.
|
||||||
|
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("HOST_PROC", dir)
|
||||||
|
t.Setenv("HOST_SYS", dir)
|
||||||
|
t.Setenv("HOST_DEV", dir)
|
||||||
|
t.Setenv("HOST_RUN", dir)
|
||||||
|
writeDiskstats := func(line string) {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
|
||||||
|
counters, err := disk.IOCounters("sda")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fs := &system.FsStats{Root: true}
|
||||||
|
a := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{"sda": fs},
|
||||||
|
diskPrev: map[uint16]map[string]prevDisk{},
|
||||||
|
}
|
||||||
|
a.initializeDiskIoStats(counters)
|
||||||
|
|
||||||
|
// updateDiskIo skips samples less than 100ms apart.
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
|
||||||
|
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
|
||||||
|
var stats system.Stats
|
||||||
|
a.updateDiskIo(60000, &stats)
|
||||||
|
|
||||||
|
require.NotZero(t, fs.DiskReadBytes, "bytes are measured from the baseline")
|
||||||
|
for i := range 3 {
|
||||||
|
assert.NotZero(t, fs.DiskIoStats[i], "DiskIoStats[%d]", i)
|
||||||
|
}
|
||||||
|
assert.InDelta(t, 30, fs.DiskIoStats[3], 0.01, "r_await")
|
||||||
|
assert.InDelta(t, 20, fs.DiskIoStats[4], 0.01, "w_await")
|
||||||
|
assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
|
||||||
|
|
||||||
|
// A second interval starts from the latest counters, not from the ones at start.
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
|
||||||
|
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
|
||||||
|
a.updateDiskIo(1000, &stats)
|
||||||
|
|
||||||
|
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
|
||||||
|
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
|
||||||
|
}
|
||||||
+117
-2
@@ -3,7 +3,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1030,8 +1032,10 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
|||||||
assert.Len(t, agent.fsNames, 2)
|
assert.Len(t, agent.fsNames, 2)
|
||||||
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
||||||
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
||||||
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
|
||||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes)
|
||||||
|
assert.False(t, agent.diskBaseline["sda"].at.IsZero())
|
||||||
|
assert.False(t, agent.diskBaseline["sdb"].at.IsZero())
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||||
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
||||||
@@ -1041,3 +1045,114 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
||||||
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
|
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
-8
@@ -68,10 +68,11 @@ type dockerManager struct {
|
|||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
|
|
||||||
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
|
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
|
||||||
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
|
||||||
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
||||||
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
||||||
|
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -688,6 +689,8 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
userAgent: "Docker-Client/",
|
userAgent: "Docker-Client/",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
|
||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
// Read container exclusion patterns from environment variable
|
||||||
var excludeContainers []string
|
var excludeContainers []string
|
||||||
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||||
@@ -707,10 +710,11 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
},
|
},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
excludeContainers: excludeContainers,
|
excludeContainers: excludeContainers,
|
||||||
|
imageUpdatesDisabled: dockerImageCheck == "false",
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func normalizedImageReference(image string) string {
|
|||||||
// refreshImageUpdates starts at most one background batch. Neither its network
|
// refreshImageUpdates starts at most one background batch. Neither its network
|
||||||
// work nor its completion is part of the container metrics wait group.
|
// work nor its completion is part of the container metrics wait group.
|
||||||
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
||||||
|
if dm.imageUpdatesDisabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
dm.imageUpdatesMutex.Lock()
|
dm.imageUpdatesMutex.Lock()
|
||||||
defer dm.imageUpdatesMutex.Unlock()
|
defer dm.imageUpdatesMutex.Unlock()
|
||||||
if dm.imageUpdatesRunning {
|
if dm.imageUpdatesRunning {
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
|
|||||||
}, time.Second*3, time.Millisecond)
|
}, time.Second*3, time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDisableDockerImageUpdateCheck(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_AGENT_DOCKER_IMAGE_CHECK", "false")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/version" {
|
||||||
|
fmt.Fprint(w, `{"Version":"25.0.0"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
t.Setenv("BESZEL_AGENT_DOCKER_HOST", server.URL)
|
||||||
|
|
||||||
|
dm := newDockerManager(nil)
|
||||||
|
require.True(t, dm.imageUpdatesDisabled)
|
||||||
|
dm.registryClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("disabled image update check made a registry request")
|
||||||
|
return nil, nil
|
||||||
|
})}
|
||||||
|
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx", Names: []string{"/nginx"}}}, time.Now())
|
||||||
|
require.False(t, dm.imageUpdatesRunning)
|
||||||
|
require.Nil(t, dm.imageUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
func TestImageUpdateCacheAndStats(t *testing.T) {
|
func TestImageUpdateCacheAndStats(t *testing.T) {
|
||||||
local := "sha256:" + strings.Repeat("a", 64)
|
local := "sha256:" + strings.Repeat("a", 64)
|
||||||
remote := "sha256:" + strings.Repeat("b", 64)
|
remote := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
|||||||
+21
-19
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|||||||
repository := reference.Path(named)
|
repository := reference.Path(named)
|
||||||
tag := named.(reference.Tagged).Tag()
|
tag := named.(reference.Tagged).Tag()
|
||||||
|
|
||||||
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
localDigests, err := dm.inspectImageDigests(image, registry, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -47,48 +48,49 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return remoteDigest != localDigest, nil
|
return !slices.Contains(localDigests, remoteDigest), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
// inspectImageDigests reads Docker's image metadata without using dm.decode.
|
||||||
// The checker runs in the image-discovery goroutine, so it must not hold any
|
// The checker runs in the image-discovery goroutine, so it must not hold any
|
||||||
// of the container statistics locks while waiting on the Docker API.
|
// of the container statistics locks while waiting on the Docker API.
|
||||||
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
func (dm *dockerManager) inspectImageDigests(image, registry, repository string) ([]string, error) {
|
||||||
if dm.client == nil {
|
if dm.client == nil {
|
||||||
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
return nil, fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
||||||
resp, err := dm.client.Get(endpoint)
|
resp, err := dm.client.Get(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("inspect image %q: %w", image, err)
|
return nil, fmt.Errorf("inspect image %q: %w", image, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
return nil, fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
var inspect struct {
|
var inspect struct {
|
||||||
RepoDigests []string `json:"RepoDigests"`
|
RepoDigests []string `json:"RepoDigests"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
||||||
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
return nil, fmt.Errorf("decode image inspect %q: %w", image, err)
|
||||||
}
|
}
|
||||||
if len(inspect.RepoDigests) == 0 {
|
if len(inspect.RepoDigests) == 0 {
|
||||||
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
return nil, fmt.Errorf("inspect image %q returned no repository digests", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
|
||||||
if !ok {
|
if len(localDigests) == 0 {
|
||||||
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
||||||
}
|
}
|
||||||
return localDigest, nil
|
return localDigests, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
// matchingRepositoryDigests returns all valid digests belonging to the requested
|
||||||
// repository. Docker can return multiple RepoDigests for one local image; an
|
// repository. Container engines can return both index and platform manifest digests for one
|
||||||
// unrelated first entry must never be used for the comparison.
|
// local image, in either order.
|
||||||
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
|
||||||
|
var digests []string
|
||||||
for _, repoDigest := range repoDigests {
|
for _, repoDigest := range repoDigests {
|
||||||
repoDigest = strings.TrimSpace(repoDigest)
|
repoDigest = strings.TrimSpace(repoDigest)
|
||||||
at := strings.LastIndexByte(repoDigest, '@')
|
at := strings.LastIndexByte(repoDigest, '@')
|
||||||
@@ -108,9 +110,9 @@ func matchingRepositoryDigest(repoDigests []string, registry, repository string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return d.String(), true
|
digests = append(digests, d.String())
|
||||||
}
|
}
|
||||||
return "", false
|
return digests
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameRegistry(left, right string) bool {
|
func sameRegistry(left, right string) bool {
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
|
|||||||
require.EqualValues(t, 1, manifestCalls.Load())
|
require.EqualValues(t, 1, manifestCalls.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckImageUpdateMatchesAnyRepositoryDigest(t *testing.T) {
|
||||||
|
platform := registryDigest('a')
|
||||||
|
index := registryDigest('b')
|
||||||
|
other := registryDigest('c')
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
digests []string
|
||||||
|
remote string
|
||||||
|
available bool
|
||||||
|
}{
|
||||||
|
{name: "platform then index, remote index", digests: []string{platform, index}, remote: index},
|
||||||
|
{name: "index then platform, remote index", digests: []string{index, platform}, remote: index},
|
||||||
|
{name: "platform then index, remote platform", digests: []string{platform, index}, remote: platform},
|
||||||
|
{name: "index then platform, remote platform", digests: []string{index, platform}, remote: platform},
|
||||||
|
{name: "neither matches", digests: []string{platform, index}, remote: other, available: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
inspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/busybox@%s","docker.io/library/alpine@%s","docker.io/library/alpine@sha256:invalid","docker.io/library/alpine@%s"]}`, test.remote, test.digests[0], test.digests[1])
|
||||||
|
var manifestCalls atomic.Int32
|
||||||
|
dm := newRegistryChecker(t, inspect, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method == http.MethodGet {
|
||||||
|
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
|
||||||
|
}
|
||||||
|
manifestCalls.Add(1)
|
||||||
|
require.Equal(t, http.MethodHead, req.Method)
|
||||||
|
resp := registryResponse(http.StatusOK, "")
|
||||||
|
resp.Header.Set("Docker-Content-Digest", test.remote)
|
||||||
|
return resp, nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
available, err := dm.checkImageUpdate("alpine")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, test.available, available)
|
||||||
|
require.EqualValues(t, 1, manifestCalls.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
|
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+18
-2
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fanSensor struct {
|
type fanSensor struct {
|
||||||
key, path string
|
key, path, chip string
|
||||||
}
|
}
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
var getFanSensors = newFanSensorCache(hwmonRoot)
|
||||||
@@ -34,6 +34,10 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
|
|||||||
slog.Debug("Error reading fans", "err", err)
|
slog.Debug("Error reading fans", "err", err)
|
||||||
return
|
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)
|
fans := readFanSensors(sensors)
|
||||||
if len(fans) == 0 {
|
if len(fans) == 0 {
|
||||||
return
|
return
|
||||||
@@ -100,7 +104,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|||||||
if label != "" {
|
if label != "" {
|
||||||
key = chipName + "_" + label
|
key = chipName + "_" + label
|
||||||
}
|
}
|
||||||
sensors = append(sensors, fanSensor{key, inputPath})
|
sensors = append(sensors, fanSensor{key, inputPath, chipName})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sensors, nil
|
return sensors, nil
|
||||||
@@ -115,3 +119,15 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
|
|||||||
}
|
}
|
||||||
return fans
|
return fans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterGpuFans drops GPU chips without touching the shared cache backing array.
|
||||||
|
func filterGpuFans(sensors []fanSensor) []fanSensor {
|
||||||
|
kept := make([]fanSensor, 0, len(sensors))
|
||||||
|
for _, sensor := range sensors {
|
||||||
|
if isGpuChipName(sensor.chip) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, sensor)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,3 +103,20 @@ func TestFanDiscoveryCache(t *testing.T) {
|
|||||||
fans = readFanSensors(sensors)
|
fans = readFanSensors(sensors)
|
||||||
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+29
-2
@@ -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.
|
// It only reports capability presence and does not apply policy decisions.
|
||||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
caps := gpuCapabilities{
|
caps := gpuCapabilities{
|
||||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
hasXe: gm.hasXe(),
|
hasXe: gm.hasXe(),
|
||||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
@@ -750,9 +750,36 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
return priorities
|
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
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
func NewGPUManager() (*GPUManager, error) {
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||||
|
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
|
|||||||
@@ -201,12 +201,9 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
if health.mismatchCnt > 0 {
|
if health.mismatchCnt > 0 {
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
// "check" scans for consistency problems without repairing mismatches.
|
// "check" and "repair" are requested consistency scans, not evidence of
|
||||||
// With no mismatches, keep it green while reporting progress attributes.
|
// array failure. With no health issues above, keep scrubbing green while
|
||||||
switch syncAction {
|
// reporting the sync action and progress attributes.
|
||||||
case "repair":
|
|
||||||
return "WARNING"
|
|
||||||
}
|
|
||||||
switch state {
|
switch state {
|
||||||
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
||||||
return "PASSED"
|
return "PASSED"
|
||||||
|
|||||||
@@ -174,8 +174,25 @@ func TestMdraidSmartStatus(t *testing.T) {
|
|||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
for _, tc := range []struct {
|
||||||
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
name string
|
||||||
|
health mdraidHealth
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"clean", mdraidHealth{arrayState: "clean"}, "PASSED"},
|
||||||
|
{"active", mdraidHealth{arrayState: "active"}, "PASSED"},
|
||||||
|
{"mismatch", mdraidHealth{arrayState: "active", mismatchCnt: 1}, "WARNING"},
|
||||||
|
{"degraded", mdraidHealth{arrayState: "active", degraded: 1}, "FAILED"},
|
||||||
|
{"faulty member", mdraidHealth{arrayState: "active", faultyDisks: 1}, "FAILED"},
|
||||||
|
{"inactive", mdraidHealth{arrayState: "inactive"}, "FAILED"},
|
||||||
|
{"unknown", mdraidHealth{arrayState: "unknown"}, "UNKNOWN"},
|
||||||
|
} {
|
||||||
|
t.Run("repair/"+tc.name, func(t *testing.T) {
|
||||||
|
tc.health.syncAction = "repair"
|
||||||
|
if got := mdraidSmartStatus(tc.health); got != tc.want {
|
||||||
|
t.Fatalf("mdraidSmartStatus(%+v) = %q, want %s", tc.health, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MonitorManager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
monitors map[string]*monitorTask // keyed by monitor ID
|
monitors map[string]*monitorTask // keyed by monitor ID
|
||||||
probe monitorProbe
|
probe monitorProbe
|
||||||
|
certCheck certChecker
|
||||||
resumeGuard monitorResumeGuard
|
resumeGuard monitorResumeGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func newMonitorManager() *MonitorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
||||||
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
|
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncMonitors replaces all monitor tasks with the given configs.
|
// SyncMonitors replaces all monitor tasks with the given configs.
|
||||||
@@ -107,7 +108,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
if !runNow {
|
if !runNow {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return task.runProbe(pm.probe), nil
|
return pm.runNow(task), nil
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -119,7 +120,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
|
|
||||||
if runNow {
|
if runNow {
|
||||||
result := task.runProbe(pm.probe)
|
result := pm.runNow(task)
|
||||||
pm.startMonitor(task)
|
pm.startMonitor(task)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,19 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runNow runs a probe and any due certificate check concurrently, so the
|
||||||
|
// response fits within the hub's single probe timeout budget.
|
||||||
|
func (pm *MonitorManager) runNow(task *monitorTask) *monitor.Result {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() { task.refreshCert(pm.certCheck) })
|
||||||
|
result := task.runProbe(pm.probe)
|
||||||
|
wg.Wait()
|
||||||
|
if result != nil {
|
||||||
|
result.Cert = task.certInfo()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteMonitor stops and removes a single monitor task.
|
// DeleteMonitor stops and removes a single monitor task.
|
||||||
func (pm *MonitorManager) DeleteMonitor(id string) {
|
func (pm *MonitorManager) DeleteMonitor(id string) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -158,6 +172,11 @@ func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Resul
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Only the default interval updates monitor records on the hub, so
|
||||||
|
// realtime requests must not consume the unsent certificate.
|
||||||
|
if durationMs == defaultDataCacheTimeMs {
|
||||||
|
result.Cert = task.takeUnsentCert()
|
||||||
|
}
|
||||||
results[task.config.ID] = result
|
results[task.config.ID] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
certCheckInterval = 24 * time.Hour
|
||||||
|
certCheckRetryInterval = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// certChecker fetches the leaf certificate for an HTTPS target.
|
||||||
|
type certChecker func(context.Context, string) (monitor.CertInfo, error)
|
||||||
|
|
||||||
|
// certCheckEnabled reports whether a monitor's certificate is checked, which is
|
||||||
|
// the case for every HTTP monitor with an https target.
|
||||||
|
func certCheckEnabled(config monitor.Config) bool {
|
||||||
|
return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkCert reads the leaf certificate presented by an HTTPS target. The chain is
|
||||||
|
// not verified, so expired or self-signed certificates are still reported.
|
||||||
|
func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) {
|
||||||
|
address, host, err := certAddress(target)
|
||||||
|
if err != nil {
|
||||||
|
return monitor.CertInfo{}, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
|
||||||
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return monitor.CertInfo{}, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return monitor.CertInfo{}, errors.New("no peer certificates")
|
||||||
|
}
|
||||||
|
leaf := certs[0]
|
||||||
|
return monitor.CertInfo{
|
||||||
|
Expires: leaf.NotAfter.UnixMilli(),
|
||||||
|
Issuer: leaf.Issuer.CommonName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// certAddress returns the dial address and server name for an HTTPS URL.
|
||||||
|
func certAddress(target string) (address, host string, err error) {
|
||||||
|
u, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(u.Scheme, "https") {
|
||||||
|
return "", "", fmt.Errorf("certificate check requires an https target: %s", target)
|
||||||
|
}
|
||||||
|
host = u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return "", "", fmt.Errorf("missing host in target: %s", target)
|
||||||
|
}
|
||||||
|
port := u.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, port), host, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckCertReadsUnverifiedLeaf(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// httptest uses a self-signed certificate, which must still be reported.
|
||||||
|
info, err := checkCert(context.Background(), server.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
leaf := server.Certificate()
|
||||||
|
assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires)
|
||||||
|
assert.Equal(t, leaf.Issuer.CommonName, info.Issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertAddress(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
target, address, host string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{target: "https://example.com", address: "example.com:443", host: "example.com"},
|
||||||
|
{target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"},
|
||||||
|
{target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"},
|
||||||
|
{target: "http://example.com", wantErr: true},
|
||||||
|
{target: "https://", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
address, host, err := certAddress(tt.target)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err, tt.target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.NoError(t, err, tt.target)
|
||||||
|
assert.Equal(t, tt.address, address)
|
||||||
|
assert.Equal(t, tt.host, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCertCadence(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
|
||||||
|
defer task.cancel()
|
||||||
|
var calls int
|
||||||
|
var fail error
|
||||||
|
// Far enough out that the regular interval applies for the whole test.
|
||||||
|
expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
|
||||||
|
check := func(context.Context, string) (monitor.CertInfo, error) {
|
||||||
|
calls++
|
||||||
|
if fail != nil {
|
||||||
|
return monitor.CertInfo{}, fail
|
||||||
|
}
|
||||||
|
return monitor.CertInfo{Expires: expires + int64(calls)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
task.refreshCert(check)
|
||||||
|
require.NotNil(t, task.certInfo())
|
||||||
|
assert.Equal(t, expires+1, task.certInfo().Expires)
|
||||||
|
|
||||||
|
// Not due again until the check interval passes.
|
||||||
|
time.Sleep(certCheckInterval - time.Second)
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
|
||||||
|
// Failures keep the last known certificate and retry sooner.
|
||||||
|
fail = errors.New("connection refused")
|
||||||
|
time.Sleep(certCheckInterval)
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 3, calls)
|
||||||
|
assert.Equal(t, expires+2, task.certInfo().Expires)
|
||||||
|
time.Sleep(certCheckRetryInterval)
|
||||||
|
fail = nil
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 4, calls)
|
||||||
|
assert.Equal(t, expires+4, task.certInfo().Expires)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
expires time.Duration // relative to the check
|
||||||
|
interval time.Duration
|
||||||
|
}{
|
||||||
|
{"expired", -time.Hour, certCheckRetryInterval},
|
||||||
|
{"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval},
|
||||||
|
{"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
|
||||||
|
defer task.cancel()
|
||||||
|
var calls int
|
||||||
|
check := func(context.Context, string) (monitor.CertInfo, error) {
|
||||||
|
calls++
|
||||||
|
return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil
|
||||||
|
}
|
||||||
|
task.refreshCert(check)
|
||||||
|
time.Sleep(tc.interval - time.Second)
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 1, calls)
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
task.refreshCert(check)
|
||||||
|
assert.Equal(t, 2, calls)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCertCheckEnabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
protocol, target string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"http", "https://example.com", true},
|
||||||
|
{"http", "HTTPS://example.com:8443/path", true},
|
||||||
|
{"http", "http://example.com", false},
|
||||||
|
{"http", "https://", false},
|
||||||
|
{"tcp", "https://example.com", false},
|
||||||
|
{"icmp", "example.com", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCertSkipsNonHTTPS(t *testing.T) {
|
||||||
|
task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"})
|
||||||
|
defer task.cancel()
|
||||||
|
task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) {
|
||||||
|
t.Fatal("certificate check must not run for non-https targets")
|
||||||
|
return monitor.CertInfo{}, nil
|
||||||
|
})
|
||||||
|
assert.Nil(t, task.certInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertMonitorRunNowIncludesCert(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil })
|
||||||
|
defer pm.Stop()
|
||||||
|
config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60}
|
||||||
|
result, err := pm.UpsertMonitor(config, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, result)
|
||||||
|
require.NotNil(t, result.Cert)
|
||||||
|
assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires)
|
||||||
|
|
||||||
|
// Realtime results never carry the certificate, and the default interval
|
||||||
|
// sends it only once per check.
|
||||||
|
assert.Nil(t, pm.GetResults(1000)["cert"].Cert)
|
||||||
|
results := pm.GetResults(defaultDataCacheTimeMs)
|
||||||
|
require.NotNil(t, results["cert"].Cert)
|
||||||
|
assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires)
|
||||||
|
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
|
||||||
|
|
||||||
|
// Changing the interval keeps the known certificate without resending it.
|
||||||
|
config.Interval = 30
|
||||||
|
_, err = pm.UpsertMonitor(config, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
pm.mu.RLock()
|
||||||
|
task := pm.monitors["cert"]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
assert.NotNil(t, task.certInfo())
|
||||||
|
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
|
||||||
|
}
|
||||||
@@ -8,9 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://beszel.dev)"
|
||||||
|
|
||||||
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
|
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
|
||||||
// Implementations must honor cancellation and bound their execution time.
|
// Implementations must honor cancellation and bound their execution time.
|
||||||
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
||||||
@@ -25,7 +28,7 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
|
|||||||
case "http":
|
case "http":
|
||||||
return monitorHTTP(ctx, client, config.Target)
|
return monitorHTTP(ctx, client, config.Target)
|
||||||
case "dns":
|
case "dns":
|
||||||
return monitorDNS(ctx, config.Target)
|
return monitorDNS(ctx, config.Target, config.Server)
|
||||||
default:
|
default:
|
||||||
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
||||||
}
|
}
|
||||||
@@ -70,19 +73,43 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
|
|||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
|
// monitorDNS measures DNS resolution response time in microseconds. If server is
|
||||||
func monitorDNS(ctx context.Context, target string) (int64, error) {
|
// non-empty, the lookup is sent to that DNS server (host or host:port, default
|
||||||
|
// port 53) instead of the system resolver. Returns -1 and an error on failure.
|
||||||
|
func monitorDNS(ctx context.Context, target, server string) (int64, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
if server != "" {
|
||||||
|
resolver = dnsResolverForServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
ips, err := net.DefaultResolver.LookupHost(ctx, target)
|
ips, err := resolver.LookupHost(ctx, target)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
return time.Since(start).Microseconds(), nil
|
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.
|
// 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) {
|
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
@@ -93,6 +120,7 @@ func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", networkMonitorUserAgent)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, err
|
return -1, err
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ func (pm *MonitorManager) startMonitor(task *monitorTask) {
|
|||||||
}
|
}
|
||||||
delay := getStagger(interval.Milliseconds())
|
delay := getStagger(interval.Milliseconds())
|
||||||
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
|
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
|
||||||
|
// Certificate checks piggyback on probe ticks, so they run at most once per
|
||||||
|
// probe interval after they become due.
|
||||||
go runMonitorSchedule(task.ctx, interval, delay, func() {
|
go runMonitorSchedule(task.ctx, interval, delay, func() {
|
||||||
if _, allowed := task.resumeGuard.snapshot(); allowed {
|
if _, allowed := task.resumeGuard.snapshot(); allowed {
|
||||||
task.runProbe(pm.probe)
|
task.runProbe(pm.probe)
|
||||||
|
task.refreshCert(pm.certCheck)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ type monitorTask struct {
|
|||||||
runMu sync.Mutex
|
runMu sync.Mutex
|
||||||
inflight *monitorRun
|
inflight *monitorRun
|
||||||
lastFailureLog int64 // Unix nanoseconds
|
lastFailureLog int64 // Unix nanoseconds
|
||||||
|
|
||||||
|
certMu sync.Mutex
|
||||||
|
cert *monitor.CertInfo
|
||||||
|
certUnsent bool // cert has not been included in a stats result yet
|
||||||
|
certChecking bool
|
||||||
|
nextCertCheck time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type monitorRun struct {
|
type monitorRun struct {
|
||||||
@@ -45,6 +51,11 @@ func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *m
|
|||||||
task := newMonitorTask(config)
|
task := newMonitorTask(config)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
task.history = existing.history.clone()
|
task.history = existing.history.clone()
|
||||||
|
// Keep the last known certificate, but check again soon for the new config.
|
||||||
|
// The hub already stores it, so it is not marked unsent.
|
||||||
|
if config.Target == existing.config.Target {
|
||||||
|
task.cert = existing.certInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
@@ -107,6 +118,70 @@ func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
|
|||||||
return copyMonitorResult(run.result)
|
return copyMonitorResult(run.result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshCert checks the certificate of an HTTPS target when due. A failed
|
||||||
|
// check keeps the last known certificate and retries sooner, as does a
|
||||||
|
// certificate that expires before the next regular check, so renewals show up
|
||||||
|
// quickly. Concurrent callers skip rather than wait, and no lock is held during
|
||||||
|
// network I/O.
|
||||||
|
func (task *monitorTask) refreshCert(check certChecker) {
|
||||||
|
if check == nil || !certCheckEnabled(task.config) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.certMu.Lock()
|
||||||
|
if task.certChecking || time.Now().Before(task.nextCertCheck) {
|
||||||
|
task.certMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.certChecking = true
|
||||||
|
task.certMu.Unlock()
|
||||||
|
|
||||||
|
info, err := check(task.ctx, task.config.Target)
|
||||||
|
|
||||||
|
task.certMu.Lock()
|
||||||
|
defer task.certMu.Unlock()
|
||||||
|
task.certChecking = false
|
||||||
|
if task.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
task.nextCertCheck = time.Now().Add(certCheckRetryInterval)
|
||||||
|
slog.Warn("certificate check failed", "err", err, "target", task.config.Target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.cert = &info
|
||||||
|
task.certUnsent = true
|
||||||
|
now := time.Now()
|
||||||
|
interval := certCheckInterval
|
||||||
|
if time.UnixMilli(info.Expires).Before(now.Add(certCheckInterval)) {
|
||||||
|
interval = certCheckRetryInterval
|
||||||
|
}
|
||||||
|
task.nextCertCheck = now.Add(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// certInfo returns a copy of the latest certificate info, or nil if unknown.
|
||||||
|
func (task *monitorTask) certInfo() *monitor.CertInfo {
|
||||||
|
task.certMu.Lock()
|
||||||
|
defer task.certMu.Unlock()
|
||||||
|
if task.cert == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cert := *task.cert
|
||||||
|
return &cert
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeUnsentCert returns the latest certificate info once after each successful
|
||||||
|
// check, so unchanged info is not resent with every stats result.
|
||||||
|
func (task *monitorTask) takeUnsentCert() *monitor.CertInfo {
|
||||||
|
task.certMu.Lock()
|
||||||
|
defer task.certMu.Unlock()
|
||||||
|
if !task.certUnsent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
task.certUnsent = false
|
||||||
|
cert := *task.cert
|
||||||
|
return &cert
|
||||||
|
}
|
||||||
|
|
||||||
func copyMonitorResult(result *monitor.Result) *monitor.Result {
|
func copyMonitorResult(result *monitor.Result) *monitor.Result {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -240,6 +241,7 @@ func TestMonitorManagerGetRandomDelay(t *testing.T) {
|
|||||||
func TestMonitorHTTP(t *testing.T) {
|
func TestMonitorHTTP(t *testing.T) {
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, "Beszel-Agent/"+beszel.Version+" (+https://beszel.dev)", r.Header.Get("User-Agent"))
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -374,15 +376,79 @@ func tcpMonitorTestResolver(ips []string) *net.Resolver {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// udpDNSTestServer starts a UDP server on loopback that answers A queries with the
|
||||||
|
// given IPs, and returns its listen address (host:port).
|
||||||
|
func udpDNSTestServer(t *testing.T, ips []string) string {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { conn.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
for {
|
||||||
|
n, addr, err := conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var msg dnsmessage.Message
|
||||||
|
if err := msg.Unpack(buf[:n]); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg.Header.Response = true
|
||||||
|
msg.Header.RecursionAvailable = true
|
||||||
|
for _, question := range msg.Questions {
|
||||||
|
if question.Type != dnsmessage.TypeA {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
msg.Answers = append(msg.Answers, dnsmessage.Resource{
|
||||||
|
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
|
||||||
|
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packet, err := msg.Pack()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = conn.WriteToUDP(packet, addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return conn.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
func TestMonitorDNS(t *testing.T) {
|
func TestMonitorDNS(t *testing.T) {
|
||||||
t.Run("success", func(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)
|
require.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("lookup failure", func(t *testing.T) {
|
t.Run("lookup failure", func(t *testing.T) {
|
||||||
responseUs, err := monitorDNS(context.Background(), "")
|
responseUs, err := monitorDNS(context.Background(), "", "")
|
||||||
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom server", func(t *testing.T) {
|
||||||
|
serverAddr := udpDNSTestServer(t, []string{"192.0.2.10"})
|
||||||
|
responseUs, err := monitorDNS(context.Background(), "example.test.", serverAddr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom server without port defaults to 53", func(t *testing.T) {
|
||||||
|
resolver := dnsResolverForServer("127.0.0.1")
|
||||||
|
conn, err := resolver.Dial(context.Background(), "udp", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
assert.Equal(t, "127.0.0.1:53", conn.RemoteAddr().String())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom server unreachable", func(t *testing.T) {
|
||||||
|
responseUs, err := monitorDNS(context.Background(), "example.test.", "127.0.0.1:1")
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
@@ -477,7 +543,7 @@ func TestMonitorResolutionCancellation(t *testing.T) {
|
|||||||
case "tcp":
|
case "tcp":
|
||||||
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
|
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
|
||||||
case "dns":
|
case "dns":
|
||||||
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
|
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.", "")
|
||||||
case "icmp":
|
case "icmp":
|
||||||
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
|
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPackageUpdatesInterval = time.Hour
|
||||||
|
packageUpdatesTimeout = 5 * time.Minute
|
||||||
|
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
|
||||||
|
// databases. Checks in between reuse the last synced copy.
|
||||||
|
pacmanSyncInterval = 12 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
|
||||||
|
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
|
||||||
|
|
||||||
|
// packageUpdatesManager periodically checks the host package manager for pending
|
||||||
|
// updates in the background and caches the result, so checks never delay metrics.
|
||||||
|
type packageUpdatesManager struct {
|
||||||
|
sync.Mutex
|
||||||
|
check packageUpdatesCheck
|
||||||
|
interval time.Duration
|
||||||
|
counts []uint16
|
||||||
|
checkedAt time.Time
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPackageUpdatesManager returns nil if disabled or no supported package manager
|
||||||
|
// is found. Agents running in a container are skipped because the container's
|
||||||
|
// package database is not the host's. dataDir holds pacman's private sync databases.
|
||||||
|
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
|
||||||
|
if runtime.GOOS != "linux" || runningInContainer() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
interval := defaultPackageUpdatesInterval
|
||||||
|
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
|
||||||
|
duration, err := time.ParseDuration(env)
|
||||||
|
switch {
|
||||||
|
case err == nil && duration == 0:
|
||||||
|
return nil
|
||||||
|
case err == nil && duration > 0:
|
||||||
|
interval = duration
|
||||||
|
default:
|
||||||
|
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name, check := detectPackageManager(dataDir)
|
||||||
|
if check == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
slog.Debug("Package updates", "manager", name, "interval", interval)
|
||||||
|
return &packageUpdatesManager{check: check, interval: interval}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the last cached counts and starts a background check if they are stale.
|
||||||
|
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
|
||||||
|
pm.Lock()
|
||||||
|
defer pm.Unlock()
|
||||||
|
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
|
||||||
|
pm.running = true
|
||||||
|
go pm.refresh()
|
||||||
|
}
|
||||||
|
return pm.counts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *packageUpdatesManager) refresh() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
||||||
|
defer cancel()
|
||||||
|
counts, err := pm.check(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Package updates check failed", "err", err)
|
||||||
|
counts = nil
|
||||||
|
}
|
||||||
|
pm.Lock()
|
||||||
|
pm.counts = counts
|
||||||
|
pm.checkedAt = time.Now()
|
||||||
|
pm.running = false
|
||||||
|
pm.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runningInContainer() bool {
|
||||||
|
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
|
||||||
|
switch {
|
||||||
|
case commandExists("apt-get"):
|
||||||
|
return "apt", checkApt
|
||||||
|
case commandExists("dnf"):
|
||||||
|
return "dnf", checkDnf
|
||||||
|
case commandExists("zypper"):
|
||||||
|
return "zypper", checkZypper
|
||||||
|
case commandExists("checkupdates"):
|
||||||
|
return "pacman", newPacmanCheck(dataDir)
|
||||||
|
case commandExists("apk"):
|
||||||
|
return "apk", checkApk
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandExists(name string) bool {
|
||||||
|
_, err := exec.LookPath(name)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPackageCommand runs a read-only package manager command and returns stdout.
|
||||||
|
// okCodes lists non-zero exit codes that still mean success.
|
||||||
|
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
|
||||||
|
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPackageCommandEnv is runPackageCommand with extra environment variables.
|
||||||
|
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||||
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
// checkupdates is a shell script, so a timeout kills only the script and its
|
||||||
|
// children can keep stdout open. WaitDelay stops Output from waiting on them.
|
||||||
|
cmd.WaitDelay = 10 * time.Second
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) {
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkApt simulates a full upgrade against the current package lists.
|
||||||
|
// It never refreshes the lists; apt-daily or the user does that.
|
||||||
|
func checkApt(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total, security := parseAptSimulate(out)
|
||||||
|
return []uint16{total, security}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
|
||||||
|
func checkDnf(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total := parseDnfCheckUpdate(out)
|
||||||
|
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
||||||
|
if err != nil {
|
||||||
|
return []uint16{total}, nil
|
||||||
|
}
|
||||||
|
return []uint16{total, parseDnfCheckUpdate(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkZypper(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
total := parseZypperTable(out)
|
||||||
|
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
||||||
|
if err != nil {
|
||||||
|
return []uint16{total}, nil
|
||||||
|
}
|
||||||
|
return []uint16{total, parseZypperTable(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
|
||||||
|
// the databases and never touches pacman's own. The copy lives in dataDir because
|
||||||
|
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
|
||||||
|
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
|
||||||
|
// Local upgrades show up right away since checkupdates links the live local DB.
|
||||||
|
// Exit code 2 means no updates.
|
||||||
|
func newPacmanCheck(dataDir string) packageUpdatesCheck {
|
||||||
|
var env []string
|
||||||
|
var syncDir string
|
||||||
|
if dataDir != "" {
|
||||||
|
dbPath := filepath.Join(dataDir, "checkup-db")
|
||||||
|
env = []string{"CHECKUPDATES_DB=" + dbPath}
|
||||||
|
syncDir = filepath.Join(dbPath, "sync")
|
||||||
|
}
|
||||||
|
// checks never overlap (packageUpdatesManager.running), so no lock is needed
|
||||||
|
var lastSync time.Time
|
||||||
|
return func(ctx context.Context) ([]uint16, error) {
|
||||||
|
// -n with a missing database reports no updates rather than failing,
|
||||||
|
// so always sync first and whenever the private copy is missing
|
||||||
|
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
|
||||||
|
if !sync && syncDir != "" {
|
||||||
|
if _, err := os.Stat(syncDir); err != nil {
|
||||||
|
sync = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var args []string
|
||||||
|
if !sync {
|
||||||
|
args = append(args, "-n")
|
||||||
|
}
|
||||||
|
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sync {
|
||||||
|
lastSync = time.Now()
|
||||||
|
}
|
||||||
|
return []uint16{parsePacmanCheckUpdates(out)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkApk(ctx context.Context) ([]uint16, error) {
|
||||||
|
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []uint16{parseApkUpgradable(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAptSimulate counts upgrades in `apt-get -s` output. Upgrade lines look like
|
||||||
|
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
|
||||||
|
// New dependencies have no "[old version]" and are not counted.
|
||||||
|
func parseAptSimulate(out string) (total, security uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total++
|
||||||
|
start := strings.IndexByte(line, '(')
|
||||||
|
end := strings.IndexByte(line, ')')
|
||||||
|
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
|
||||||
|
security++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total, security
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
|
||||||
|
// obsoletes section so obsoleted packages are not counted twice.
|
||||||
|
func parseDnfCheckUpdate(out string) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "Obsoleting") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) == 3 && strings.Contains(fields[0], ".") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseZypperTable counts the data rows of a zypper table (the lines after the
|
||||||
|
// "---+---" separator).
|
||||||
|
func parseZypperTable(out string) (count uint16) {
|
||||||
|
inTable := false
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
switch {
|
||||||
|
case !inTable:
|
||||||
|
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
|
||||||
|
case strings.Contains(line, "|"):
|
||||||
|
count++
|
||||||
|
default:
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePacmanCheckUpdates counts "name old -> new" lines.
|
||||||
|
func parsePacmanCheckUpdates(out string) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
if strings.Contains(scanner.Text(), " -> ") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseApkUpgradable counts lines of `apk -u list`, which look like
|
||||||
|
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
|
||||||
|
func parseApkUpgradable(out string) (count uint16) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||||
|
for scanner.Scan() {
|
||||||
|
if strings.Contains(scanner.Text(), "[upgradable from:") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readPackageUpdatesTestData(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join("test-data", "package_updates", name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data files are real command outputs captured in containers.
|
||||||
|
|
||||||
|
func TestParseAptSimulate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
total, security uint16
|
||||||
|
}{
|
||||||
|
{"apt_debian12.txt", 44, 5},
|
||||||
|
{"apt_ubuntu2204.txt", 58, 45},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
|
||||||
|
assert.Equal(t, tt.total, total)
|
||||||
|
assert.Equal(t, tt.security, security)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
|
||||||
|
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
||||||
|
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
||||||
|
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
||||||
|
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
||||||
|
Remv oldpkg [1.0]`
|
||||||
|
total, security := parseAptSimulate(out)
|
||||||
|
assert.Equal(t, uint16(2), total)
|
||||||
|
assert.Equal(t, uint16(1), security)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no updates", func(t *testing.T) {
|
||||||
|
total, security := parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n")
|
||||||
|
assert.Zero(t, total)
|
||||||
|
assert.Zero(t, security)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDnfCheckUpdate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
count uint16
|
||||||
|
}{
|
||||||
|
{"dnf4_rocky9_check_update.txt", 110},
|
||||||
|
{"dnf4_rocky9_check_update_security.txt", 53},
|
||||||
|
{"dnf5_fedora42_check_update.txt", 20},
|
||||||
|
{"dnf5_fedora42_check_update_security.txt", 5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.count, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("obsoletes section and notices", func(t *testing.T) {
|
||||||
|
out := `
|
||||||
|
kernel.x86_64 5.14.0-503.el9 baseos
|
||||||
|
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
|
||||||
|
Obsoleting Packages
|
||||||
|
grub2-tools.x86_64 1:2.06-80.el9 baseos
|
||||||
|
grub2-tools.x86_64 1:2.06-77.el9 @baseos
|
||||||
|
`
|
||||||
|
assert.Equal(t, uint16(1), parseDnfCheckUpdate(out))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseZypperTable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
file string
|
||||||
|
count uint16
|
||||||
|
}{
|
||||||
|
{"zypper_leap155_list_updates.txt", 22},
|
||||||
|
{"zypper_leap155_list_patches_security.txt", 4},
|
||||||
|
{"zypper_leap156_list_updates_none.txt", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.count, parseZypperTable(readPackageUpdatesTestData(t, tt.file)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePacmanCheckUpdates(t *testing.T) {
|
||||||
|
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
|
||||||
|
assert.Zero(t, parsePacmanCheckUpdates(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseApkUpgradable(t *testing.T) {
|
||||||
|
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
|
||||||
|
assert.Zero(t, parseApkUpgradable(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageUpdatesManagerCaching(t *testing.T) {
|
||||||
|
calls := make(chan struct{}, 10)
|
||||||
|
result := []uint16{3, 1}
|
||||||
|
var resultErr error
|
||||||
|
pm := &packageUpdatesManager{
|
||||||
|
interval: time.Hour,
|
||||||
|
check: func(context.Context) ([]uint16, error) {
|
||||||
|
calls <- struct{}{}
|
||||||
|
return result, resultErr
|
||||||
|
},
|
||||||
|
}
|
||||||
|
waitIdle := func() {
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
pm.Lock()
|
||||||
|
defer pm.Unlock()
|
||||||
|
return !pm.running
|
||||||
|
}, time.Second, time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
// first call starts a background check and returns nothing yet
|
||||||
|
assert.Nil(t, pm.get(now))
|
||||||
|
waitIdle()
|
||||||
|
assert.Len(t, calls, 1)
|
||||||
|
|
||||||
|
// cached result within interval, no new check
|
||||||
|
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
|
||||||
|
assert.Len(t, calls, 1)
|
||||||
|
|
||||||
|
// stale after interval: returns cached value and refreshes in background
|
||||||
|
result, resultErr = nil, errors.New("boom")
|
||||||
|
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
|
||||||
|
waitIdle()
|
||||||
|
assert.Len(t, calls, 2)
|
||||||
|
|
||||||
|
// failed check clears the counts
|
||||||
|
assert.Nil(t, pm.get(time.Now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacmanCheckSync(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("requires a shell script on PATH")
|
||||||
|
}
|
||||||
|
binDir := t.TempDir()
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(binDir, "calls.log")
|
||||||
|
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
|
||||||
|
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
|
||||||
|
echo "linux 6.1-1 -> 6.2-1"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
check := newPacmanCheck(dataDir)
|
||||||
|
dbPath := filepath.Join(dataDir, "checkup-db")
|
||||||
|
readCalls := func() []string {
|
||||||
|
data, err := os.ReadFile(logFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// first check syncs
|
||||||
|
counts, err := check(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []uint16{1}, counts)
|
||||||
|
// later checks reuse the synced copy
|
||||||
|
_, err = check(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
// a missing private copy forces a sync
|
||||||
|
require.NoError(t, os.RemoveAll(dbPath))
|
||||||
|
_, err = check(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
"args=[] db=" + dbPath,
|
||||||
|
"args=[-n] db=" + dbPath,
|
||||||
|
"args=[] db=" + dbPath,
|
||||||
|
}, readCalls())
|
||||||
|
}
|
||||||
+123
-2
@@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +34,8 @@ type SensorConfig struct {
|
|||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
|
skipGPU bool
|
||||||
|
sensorShadow string
|
||||||
firstRun bool
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +45,14 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
|||||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
||||||
|
skipGPU, _ := utils.GetEnv("SKIP_GPU")
|
||||||
|
|
||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig {
|
||||||
timeout := 2 * time.Second
|
timeout := 2 * time.Second
|
||||||
if sensorsTimeout != "" {
|
if sensorsTimeout != "" {
|
||||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||||
@@ -62,6 +67,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
|
|||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
|
skipGPU: skipGPU,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
@@ -73,6 +79,19 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
|
|||||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
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
|
// handle blacklist
|
||||||
if strings.HasPrefix(sensorsEnvVal, "-") {
|
if strings.HasPrefix(sensorsEnvVal, "-") {
|
||||||
@@ -149,6 +168,9 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
if !isValidSensor(sensorName, a.sensorConfig) {
|
if !isValidSensor(sensorName, a.sensorConfig) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// set dashboard temperature
|
// set dashboard temperature
|
||||||
switch a.sensorConfig.primarySensor {
|
switch a.sensorConfig.primarySensor {
|
||||||
case "":
|
case "":
|
||||||
@@ -245,3 +267,102 @@ func scaleTemperature(temp float64) float64 {
|
|||||||
}
|
}
|
||||||
return scaled100
|
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
|
||||||
|
}
|
||||||
|
|||||||
+143
-1
@@ -5,6 +5,8 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -328,7 +330,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -620,3 +622,143 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
||||||
assert.Equal(t, map[string]float64{}, stats.Temperatures)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+71
-21
@@ -54,12 +54,18 @@ type poolBackend struct {
|
|||||||
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
||||||
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
||||||
|
|
||||||
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
// Utility-backed caches below are refreshed in the background after the
|
||||||
lastPoolStats time.Time
|
// first collection, so cacheMu guards them against those goroutines.
|
||||||
kernelSamples map[string]poolKernelSample
|
cacheMu sync.Mutex
|
||||||
|
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
||||||
|
lastPoolStats time.Time
|
||||||
|
poolRefreshing bool
|
||||||
|
|
||||||
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
||||||
lastUsageRefresh time.Time
|
lastUsageRefresh time.Time
|
||||||
|
usageRefreshing bool
|
||||||
|
|
||||||
|
kernelSamples map[string]poolKernelSample
|
||||||
|
|
||||||
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
||||||
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
||||||
@@ -177,21 +183,42 @@ func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// poolStats returns the cached pool inventory, calling its collector at most
|
// poolStats returns the cached pool inventory, calling its collector at most
|
||||||
// every poolStatsRefreshInterval. On failure the previous inventory is
|
// every poolStatsRefreshInterval. Only the first collection blocks; later
|
||||||
// retained and the refresh is retried on the next cadence.
|
// refreshes run in the background because utilities like `zpool list` can hang
|
||||||
|
// for seconds on busy hosts, which would otherwise delay the hub's stats
|
||||||
|
// response. On failure the previous inventory is retained and the refresh is
|
||||||
|
// retried on the next cadence.
|
||||||
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
||||||
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
b.cacheMu.Lock()
|
||||||
pools, err := b.poolStatsFn()
|
defer b.cacheMu.Unlock()
|
||||||
if err != nil {
|
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
|
||||||
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
return b.poolData
|
||||||
} else {
|
|
||||||
b.poolData = pools
|
|
||||||
}
|
|
||||||
b.lastPoolStats = time.Now()
|
|
||||||
}
|
}
|
||||||
|
if b.lastPoolStats.IsZero() {
|
||||||
|
b.storePoolStats(b.poolStatsFn())
|
||||||
|
return b.poolData
|
||||||
|
}
|
||||||
|
b.poolRefreshing = true
|
||||||
|
go func() {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
b.cacheMu.Lock()
|
||||||
|
defer b.cacheMu.Unlock()
|
||||||
|
b.poolRefreshing = false
|
||||||
|
b.storePoolStats(pools, err)
|
||||||
|
}()
|
||||||
return b.poolData
|
return b.poolData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storePoolStats records a pool inventory result. Callers must hold cacheMu.
|
||||||
|
func (b *poolBackend) storePoolStats(pools []zfs.PoolStat, err error) {
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
||||||
|
} else {
|
||||||
|
b.poolData = pools
|
||||||
|
}
|
||||||
|
b.lastPoolStats = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
// kernelStats reads cumulative pool counters and converts them to per-second
|
// kernelStats reads cumulative pool counters and converts them to per-second
|
||||||
// rates. Counter decreases indicate a pool export/import and reset the
|
// rates. Counter decreases indicate a pool export/import and reset the
|
||||||
// baseline instead of producing an underflow spike.
|
// baseline instead of producing an underflow spike.
|
||||||
@@ -225,12 +252,33 @@ func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]z
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
||||||
// and rebuilds the mountpoint-keyed usage map.
|
// and returns the mountpoint-keyed usage map. Like poolStats, only the first
|
||||||
func (b *poolBackend) refreshDatasetUsage() {
|
// collection blocks and later refreshes run in the background.
|
||||||
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
|
||||||
return
|
b.cacheMu.Lock()
|
||||||
|
defer b.cacheMu.Unlock()
|
||||||
|
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
|
||||||
|
return b.datasetUsage
|
||||||
}
|
}
|
||||||
datasets, err := b.datasets()
|
if b.lastUsageRefresh.IsZero() {
|
||||||
|
b.storeDatasetUsage(b.datasets())
|
||||||
|
return b.datasetUsage
|
||||||
|
}
|
||||||
|
b.usageRefreshing = true
|
||||||
|
go func() {
|
||||||
|
datasets, err := b.datasets()
|
||||||
|
b.cacheMu.Lock()
|
||||||
|
defer b.cacheMu.Unlock()
|
||||||
|
b.usageRefreshing = false
|
||||||
|
b.storeDatasetUsage(datasets, err)
|
||||||
|
}()
|
||||||
|
return b.datasetUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeDatasetUsage rebuilds the usage map from a dataset listing. The map is
|
||||||
|
// replaced rather than mutated so returned references stay safe to read.
|
||||||
|
// Callers must hold cacheMu.
|
||||||
|
func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -251,8 +299,7 @@ func (b *poolBackend) refreshDatasetUsage() {
|
|||||||
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
||||||
for _, backend := range m.backends {
|
for _, backend := range m.backends {
|
||||||
if backend.name == "zfs" {
|
if backend.name == "zfs" {
|
||||||
backend.refreshDatasetUsage()
|
return backend.refreshDatasetUsage()
|
||||||
return backend.datasetUsage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -442,7 +489,10 @@ func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, backend := range m.backends {
|
for _, backend := range m.backends {
|
||||||
for _, pool := range backend.poolData {
|
backend.cacheMu.Lock()
|
||||||
|
pools := backend.poolData
|
||||||
|
backend.cacheMu.Unlock()
|
||||||
|
for _, pool := range pools {
|
||||||
sample := stats.ZfsPools[pool.Name]
|
sample := stats.ZfsPools[pool.Name]
|
||||||
if sample == nil || pool.MountID == "" {
|
if sample == nil || pool.MountID == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -518,3 +518,31 @@ func TestBtrfsPoolIdentities(t *testing.T) {
|
|||||||
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
||||||
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStaleUtilityCachesRefreshInBackground(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
b := &poolBackend{name: "zfs"}
|
||||||
|
b.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
||||||
|
<-release
|
||||||
|
return []zfs.PoolStat{{Name: "new"}}, nil
|
||||||
|
}
|
||||||
|
b.datasetsFn = func() ([]zfs.Dataset, error) {
|
||||||
|
<-release
|
||||||
|
return []zfs.Dataset{{Name: "new", Mountpoint: "/new"}}, nil
|
||||||
|
}
|
||||||
|
b.poolData = []zfs.PoolStat{{Name: "old"}}
|
||||||
|
b.lastPoolStats = time.Now().Add(-2 * poolStatsRefreshInterval)
|
||||||
|
b.datasetUsage = map[string]zfsDatasetUsage{"/old": {}}
|
||||||
|
b.lastUsageRefresh = time.Now().Add(-2 * datasetUsageRefreshInterval)
|
||||||
|
|
||||||
|
// A hung utility must not block collection; cached data is served meanwhile.
|
||||||
|
for range 2 {
|
||||||
|
assert.Equal(t, "old", b.poolStats()[0].Name)
|
||||||
|
assert.Contains(t, b.refreshDatasetUsage(), "/old")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return b.poolStats()[0].Name == "new" && b.refreshDatasetUsage()["/new"] == zfsDatasetUsage{}
|
||||||
|
}, time.Second, time.Millisecond)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/agent/wifi"
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -267,6 +268,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wi-Fi collection spawns a process on macOS and dumps the BSS cache on
|
||||||
|
// Linux, so only refresh on the default interval. Real-time requests reuse
|
||||||
|
// the last snapshot.
|
||||||
|
if cacheTimeMs == defaultDataCacheTimeMs {
|
||||||
|
a.systemInfo.WiFi = wifi.Collect()
|
||||||
|
}
|
||||||
|
systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi)
|
||||||
|
|
||||||
// update system info
|
// update system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
apk-tools-2.14.4-r1 aarch64 {apk-tools} (GPL-2.0-only) [upgradable from: apk-tools-2.14.4-r0]
|
||||||
|
busybox-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r28]
|
||||||
|
busybox-binsh-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-binsh-1.36.1-r28]
|
||||||
|
ca-certificates-bundle-20260413-r0 aarch64 {ca-certificates} (MPL-2.0 AND MIT) [upgradable from: ca-certificates-bundle-20240226-r0]
|
||||||
|
libcrypto3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.0-r2]
|
||||||
|
libssl3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.3.0-r2]
|
||||||
|
musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]
|
||||||
|
musl-utils-1.2.5-r3 aarch64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [upgradable from: musl-utils-1.2.5-r0]
|
||||||
|
ssl_client-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: ssl_client-1.36.1-r28]
|
||||||
|
zlib-1.3.2-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.1-r1]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
Reading package lists...
|
||||||
|
Building dependency tree...
|
||||||
|
Reading state information...
|
||||||
|
Calculating upgrade...
|
||||||
|
The following packages will be upgraded:
|
||||||
|
base-files bash bsdutils debian-archive-keyring dpkg e2fsprogs gcc-12-base
|
||||||
|
gpgv init-system-helpers libblkid1 libc-bin libc6 libcap2 libcom-err2
|
||||||
|
libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 liblzma5 libmount1
|
||||||
|
libpam-modules libpam-modules-bin libpam-runtime libpam0g libpcre2-8-0
|
||||||
|
libseccomp2 libsmartcols1 libss2 libstdc++6 libsystemd0 libtasn1-6 libudev1
|
||||||
|
libuuid1 login logsave mount passwd perl-base sed tar tzdata usr-is-merged
|
||||||
|
util-linux util-linux-extra
|
||||||
|
44 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
||||||
|
Inst base-files [12.4+deb12u4] (12.4+deb12u15 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf base-files (12.4+deb12u15 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst bash [5.2.15-2+b2] (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf bash (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst bsdutils [1:2.38.1-5+b1] (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf bsdutils (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst tar [1.34+dfsg-1.2] (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf tar (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst dpkg [1.21.22] (1.21.23 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf dpkg (1.21.23 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf login (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst perl-base [5.36.0-7+deb12u1] (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf perl-base (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst sed [4.9-1] (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf sed (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst gcc-12-base [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
||||||
|
Conf gcc-12-base (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
||||||
|
Inst libgcc-s1 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
|
||||||
|
Conf libgcc-s1 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
|
||||||
|
Inst libstdc++6 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libstdc++6 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libc6 [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libc6 (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libsmartcols1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libsmartcols1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst util-linux-extra [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf util-linux-extra (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst util-linux [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf util-linux (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst usr-is-merged [35] (37~deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Conf usr-is-merged (37~deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Inst init-system-helpers [1.65.2] (1.65.2+deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Conf init-system-helpers (1.65.2+deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Inst libc-bin [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libc-bin (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libpam0g [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libpam0g (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libpam-modules-bin [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
|
||||||
|
Conf libpam-modules-bin (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 ]
|
||||||
|
Inst libpam-modules [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libpam-modules (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst logsave [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libext2fs2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
|
||||||
|
Conf libext2fs2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 ]
|
||||||
|
Inst e2fsprogs [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst mount [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libpam-runtime [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
|
||||||
|
Conf libpam-runtime (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
|
||||||
|
Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf passwd (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst debian-archive-keyring [2023.3+deb12u1] (2023.3+deb12u2 Debian:12.15/oldstable [all])
|
||||||
|
Conf debian-archive-keyring (2023.3+deb12u2 Debian:12.15/oldstable [all])
|
||||||
|
Inst libgcrypt20 [1.10.1-3] (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Conf libgcrypt20 (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Inst gpgv [2.2.40-1.1] (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf gpgv (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libblkid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libblkid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libcap2 [1:2.66-4] (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libcap2 (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libtasn1-6 [4.19.0-2] (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Conf libtasn1-6 (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Inst libgnutls30 [3.7.9-2+deb12u1] (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Conf libgnutls30 (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Inst liblzma5 [5.4.1-0.2] (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Conf liblzma5 (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Inst libmount1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libmount1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libpcre2-8-0 [10.42-1] (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Conf libpcre2-8-0 (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
|
||||||
|
Inst libseccomp2 [2.5.4-1+b3] (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libseccomp2 (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libsystemd0 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libsystemd0 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libudev1 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libudev1 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libuuid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libuuid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst tzdata [2023c-5+deb12u1] (2026b-0+deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Inst libcom-err2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Inst libss2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf logsave (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf e2fsprogs (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf mount (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf tzdata (2026b-0+deb12u1 Debian:12.15/oldstable [all])
|
||||||
|
Conf libcom-err2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
|
Conf libss2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
Reading package lists...
|
||||||
|
Building dependency tree...
|
||||||
|
Reading state information...
|
||||||
|
Calculating upgrade...
|
||||||
|
The following packages will be upgraded:
|
||||||
|
apt base-files bash bsdutils coreutils diffutils dpkg e2fsprogs gcc-12-base
|
||||||
|
gpgv gzip libapt-pkg6.0 libattr1 libblkid1 libbz2-1.0 libc-bin libc6 libcap2
|
||||||
|
libcom-err2 libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 libgssapi-krb5-2
|
||||||
|
libk5crypto3 libkrb5-3 libkrb5support0 liblzma5 libmount1 libncurses6
|
||||||
|
libncursesw6 libp11-kit0 libpam-modules libpam-modules-bin libpam-runtime
|
||||||
|
libpam0g libprocps8 libseccomp2 libsmartcols1 libss2 libssl3 libstdc++6
|
||||||
|
libsystemd0 libtasn1-6 libtinfo6 libudev1 libuuid1 login logsave mount
|
||||||
|
ncurses-base ncurses-bin passwd perl-base procps sed tar util-linux
|
||||||
|
58 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
||||||
|
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
||||||
|
Conf gcc-12-base (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
||||||
|
Inst libgcc-s1 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
|
||||||
|
Conf libgcc-s1 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
|
||||||
|
Inst libstdc++6 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libstdc++6 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libc6 (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst base-files [12ubuntu4.4] (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf base-files (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst bash [5.1-6ubuntu1] (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf bash (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst bsdutils [1:2.37.2-4ubuntu3] (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf bsdutils (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst coreutils [8.32-4.1ubuntu1] (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf coreutils (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst diffutils [1:3.8-0ubuntu2] (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf diffutils (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libbz2-1.0 [1.0.8-5build1] (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libbz2-1.0 (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libgcrypt20 [1.9.4-3ubuntu3] (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libgcrypt20 (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst liblzma5 [5.2.5-2ubuntu1] (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf liblzma5 (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libsystemd0 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libsystemd0 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libudev1 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libudev1 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libapt-pkg6.0 [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf libapt-pkg6.0 (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst tar [1.34+dfsg-1ubuntu0.1.22.04.1] (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf tar (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst dpkg [1.21.1ubuntu2.2] (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf dpkg (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst gzip [1.10-4ubuntu4.1] (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf gzip (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst login [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf login (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst ncurses-bin [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf ncurses-bin (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst perl-base [5.34.0-3ubuntu1.2] (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf perl-base (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst sed [4.8-1ubuntu2] (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf sed (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst util-linux [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf util-linux (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libc-bin [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libc-bin (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst ncurses-base [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
||||||
|
Conf ncurses-base (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
||||||
|
Inst gpgv [2.2.27-3ubuntu2.1] (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf gpgv (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libp11-kit0 [0.24.0-6build1] (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libp11-kit0 (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libtasn1-6 [4.18.0-4build1] (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libtasn1-6 (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libgnutls30 [3.7.3-4ubuntu1.2] (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libgnutls30 (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libseccomp2 [2.5.3-2ubuntu2] (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf libseccomp2 (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst apt [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf apt (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst libpam0g [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libpam0g (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libpam-modules-bin [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
|
||||||
|
Conf libpam-modules-bin (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 ]
|
||||||
|
Inst libpam-modules [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libpam-modules (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst logsave [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst libext2fs2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
|
||||||
|
Conf libext2fs2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 ]
|
||||||
|
Inst e2fsprogs [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst mount [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libattr1 [1:2.5.1-1build1] (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libattr1 (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libblkid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libblkid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libcap2 [1:2.44-1ubuntu0.22.04.1] (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libcap2 (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libcom-err2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf libcom-err2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst libk5crypto3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf libk5crypto3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst libkrb5support0 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
|
||||||
|
Conf libkrb5support0 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
|
||||||
|
Inst libkrb5-3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
|
||||||
|
Conf libkrb5-3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
|
||||||
|
Inst libgssapi-krb5-2 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf libgssapi-krb5-2 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libssl3 (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libmount1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libmount1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libpam-runtime [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
||||||
|
Conf libpam-runtime (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
||||||
|
Inst libsmartcols1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libsmartcols1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libncurses6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
|
||||||
|
Inst libncursesw6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
|
||||||
|
Inst libtinfo6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libtinfo6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libuuid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libuuid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst passwd [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf passwd (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libprocps8 [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Inst libss2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Inst procps [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf logsave (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf e2fsprogs (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf mount (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libncurses6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libncursesw6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libprocps8 (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
|
Conf libss2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
||||||
|
Conf procps (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
alternatives.aarch64 1.24-2.el9 baseos
|
||||||
|
audit-libs.aarch64 3.1.5-8.el9 baseos
|
||||||
|
basesystem.noarch 11-13.el9.0.1 baseos
|
||||||
|
bash.aarch64 5.1.8-9.el9 baseos
|
||||||
|
binutils.aarch64 2.35.2-72.el9 baseos
|
||||||
|
binutils-gold.aarch64 2.35.2-72.el9 baseos
|
||||||
|
bzip2-libs.aarch64 1.0.8-11.el9 baseos
|
||||||
|
ca-certificates.noarch 2025.2.80_v9.0.305-91.el9 baseos
|
||||||
|
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
|
||||||
|
cracklib.aarch64 2.9.6-28.el9 baseos
|
||||||
|
cracklib-dicts.aarch64 2.9.6-28.el9 baseos
|
||||||
|
crypto-policies.noarch 20260224-1.gitea0f072.el9 baseos
|
||||||
|
crypto-policies-scripts.noarch 20260224-1.gitea0f072.el9 baseos
|
||||||
|
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
||||||
|
cyrus-sasl-lib.aarch64 2.1.27-22.el9_7 baseos
|
||||||
|
dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
||||||
|
dnf-data.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
||||||
|
elfutils-debuginfod-client.aarch64 0.194-1.el9.rocky.0.1 baseos
|
||||||
|
elfutils-default-yama-scope.noarch 0.194-1.el9.rocky.0.1 baseos
|
||||||
|
elfutils-libelf.aarch64 0.194-1.el9.rocky.0.1 baseos
|
||||||
|
elfutils-libs.aarch64 0.194-1.el9.rocky.0.1 baseos
|
||||||
|
expat.aarch64 2.5.0-6.el9_8.3 baseos
|
||||||
|
file-libs.aarch64 5.39-17.el9 baseos
|
||||||
|
filesystem.aarch64 3.16-5.el9 baseos
|
||||||
|
findutils.aarch64 1:4.8.0-7.el9 baseos
|
||||||
|
gdbm-libs.aarch64 1:1.23-1.el9 baseos
|
||||||
|
glib2.aarch64 2.68.4-19.el9_8.10 baseos
|
||||||
|
glibc.aarch64 2.34-275.el9_8 baseos
|
||||||
|
glibc-common.aarch64 2.34-275.el9_8 baseos
|
||||||
|
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
|
||||||
|
gnupg2.aarch64 2.3.3-5.el9_7 baseos
|
||||||
|
gnutls.aarch64 3.8.10-8.el9_8 baseos
|
||||||
|
gzip.aarch64 1.12-2.el9_8 baseos
|
||||||
|
ima-evm-utils.aarch64 1.6.2-2.el9.rocky.0.2 baseos
|
||||||
|
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
|
||||||
|
less.aarch64 590-6.el9 baseos
|
||||||
|
libacl.aarch64 2.4.0-1.el9_8 baseos
|
||||||
|
libarchive.aarch64 3.5.3-11.el9_8 baseos
|
||||||
|
libatomic.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libattr.aarch64 2.6.0-1.el9_8 baseos
|
||||||
|
libblkid.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libcap.aarch64 2.48-10.el9_7.1 baseos
|
||||||
|
libcom_err.aarch64 1.46.5-8.el9 baseos
|
||||||
|
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
||||||
|
libdb.aarch64 5.3.28-57.el9_6 baseos
|
||||||
|
libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
||||||
|
libeconf.aarch64 0.4.1-7.el9_8 baseos
|
||||||
|
libevent.aarch64 2.1.13-1.el9_8 baseos
|
||||||
|
libfdisk.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libgcc.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
|
||||||
|
libgomp.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libksba.aarch64 1.5.1-7.el9 baseos
|
||||||
|
libmount.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
|
||||||
|
librepo.aarch64 1.19.0-1.el9 baseos
|
||||||
|
libselinux.aarch64 3.6-3.el9 baseos
|
||||||
|
libsemanage.aarch64 3.6-5.el9_6 baseos
|
||||||
|
libsepol.aarch64 3.6-3.el9 baseos
|
||||||
|
libsmartcols.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libsolv.aarch64 0.7.24-6.el9_8 baseos
|
||||||
|
libstdc++.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libtasn1.aarch64 4.16.0-10.el9_8 baseos
|
||||||
|
libusbx.aarch64 1.0.30-1.el9_8 baseos
|
||||||
|
libuser.aarch64 0.63-17.el9 baseos
|
||||||
|
libuuid.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
|
||||||
|
libzstd.aarch64 1.5.5-1.el9 baseos
|
||||||
|
mpfr.aarch64 4.1.0-10.el9 baseos
|
||||||
|
ncurses-base.noarch 6.2-12.20210508.el9 baseos
|
||||||
|
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
|
||||||
|
nettle.aarch64 3.10.1-1.el9 baseos
|
||||||
|
openldap.aarch64 2.6.8-4.el9.0.1 baseos
|
||||||
|
openssl.aarch64 1:3.5.8-1.el9_8 baseos
|
||||||
|
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
|
||||||
|
p11-kit.aarch64 0.26.4-1.el9_8 baseos
|
||||||
|
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
|
||||||
|
pam.aarch64 1.5.1-28.el9_8.1 baseos
|
||||||
|
pcre.aarch64 8.44-4.el9 baseos
|
||||||
|
pcre2.aarch64 10.40-6.el9 baseos
|
||||||
|
pcre2-syntax.noarch 10.40-6.el9 baseos
|
||||||
|
python3.aarch64 3.9.25-7.el9_8.3 baseos
|
||||||
|
python3-dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
||||||
|
python3-hawkey.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
||||||
|
python3-libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
||||||
|
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
|
||||||
|
python3-pip-wheel.noarch 21.3.1-2.el9_8.rocky.0.1 baseos
|
||||||
|
python3-rpm.aarch64 4.16.1.3-40.el9 baseos
|
||||||
|
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
|
||||||
|
rocky-gpg-keys.noarch 9.8-1.2.el9 baseos
|
||||||
|
rocky-release.noarch 9.8-1.2.el9 baseos
|
||||||
|
rocky-repos.noarch 9.8-1.2.el9 baseos
|
||||||
|
rootfiles.noarch 8.1-35.el9 baseos
|
||||||
|
rpm.aarch64 4.16.1.3-40.el9 baseos
|
||||||
|
rpm-build-libs.aarch64 4.16.1.3-40.el9 baseos
|
||||||
|
rpm-libs.aarch64 4.16.1.3-40.el9 baseos
|
||||||
|
rpm-sign-libs.aarch64 4.16.1.3-40.el9 baseos
|
||||||
|
sed.aarch64 4.8-10.el9_8 baseos
|
||||||
|
setup.noarch 2.13.7-10.el9 baseos
|
||||||
|
shadow-utils.aarch64 2:4.9-16.el9 baseos
|
||||||
|
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
|
||||||
|
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
|
||||||
|
tar.aarch64 2:1.34-13.el9_8 baseos
|
||||||
|
tpm2-tss.aarch64 3.2.3-1.el9 baseos
|
||||||
|
tzdata.noarch 2026c-1.el9_8 baseos
|
||||||
|
usermode.aarch64 1.114-7.el9 baseos
|
||||||
|
util-linux.aarch64 2.37.4-25.el9 baseos
|
||||||
|
util-linux-core.aarch64 2.37.4-25.el9 baseos
|
||||||
|
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
|
||||||
|
yum.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
binutils.aarch64 2.35.2-72.el9 baseos
|
||||||
|
binutils-gold.aarch64 2.35.2-72.el9 baseos
|
||||||
|
bzip2-libs.aarch64 1.0.8-11.el9 baseos
|
||||||
|
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
|
||||||
|
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
||||||
|
expat.aarch64 2.5.0-6.el9_8.3 baseos
|
||||||
|
file-libs.aarch64 5.39-17.el9 baseos
|
||||||
|
glib2.aarch64 2.68.4-19.el9_8.10 baseos
|
||||||
|
glibc.aarch64 2.34-275.el9_8 baseos
|
||||||
|
glibc-common.aarch64 2.34-275.el9_8 baseos
|
||||||
|
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
|
||||||
|
gnupg2.aarch64 2.3.3-5.el9_7 baseos
|
||||||
|
gnutls.aarch64 3.8.10-8.el9_8 baseos
|
||||||
|
gzip.aarch64 1.12-2.el9_8 baseos
|
||||||
|
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
|
||||||
|
less.aarch64 590-6.el9 baseos
|
||||||
|
libacl.aarch64 2.4.0-1.el9_8 baseos
|
||||||
|
libarchive.aarch64 3.5.3-11.el9_8 baseos
|
||||||
|
libatomic.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libattr.aarch64 2.6.0-1.el9_8 baseos
|
||||||
|
libblkid.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libcap.aarch64 2.48-10.el9_7.1 baseos
|
||||||
|
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
||||||
|
libevent.aarch64 2.1.13-1.el9_8 baseos
|
||||||
|
libfdisk.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libgcc.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
|
||||||
|
libgomp.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libmount.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
|
||||||
|
libsmartcols.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libsolv.aarch64 0.7.24-6.el9_8 baseos
|
||||||
|
libstdc++.aarch64 11.5.0-14.el9 baseos
|
||||||
|
libtasn1.aarch64 4.16.0-10.el9_8 baseos
|
||||||
|
libuuid.aarch64 2.37.4-25.el9 baseos
|
||||||
|
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
|
||||||
|
ncurses-base.noarch 6.2-12.20210508.el9 baseos
|
||||||
|
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
|
||||||
|
openssl.aarch64 1:3.5.8-1.el9_8 baseos
|
||||||
|
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
|
||||||
|
p11-kit.aarch64 0.26.4-1.el9_8 baseos
|
||||||
|
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
|
||||||
|
pam.aarch64 1.5.1-28.el9_8.1 baseos
|
||||||
|
python3.aarch64 3.9.25-7.el9_8.3 baseos
|
||||||
|
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
|
||||||
|
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
|
||||||
|
shadow-utils.aarch64 2:4.9-16.el9 baseos
|
||||||
|
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
|
||||||
|
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
|
||||||
|
tar.aarch64 2:1.34-13.el9_8 baseos
|
||||||
|
util-linux.aarch64 2.37.4-25.el9 baseos
|
||||||
|
util-linux-core.aarch64 2.37.4-25.el9 baseos
|
||||||
|
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
dnf5.aarch64 5.2.18.0-3.fc42 updates
|
||||||
|
dnf5-plugins.aarch64 5.2.18.0-3.fc42 updates
|
||||||
|
elfutils-default-yama-scope.noarch 0.195-1.fc42 updates
|
||||||
|
elfutils-libelf.aarch64 0.195-1.fc42 updates
|
||||||
|
elfutils-libs.aarch64 0.195-1.fc42 updates
|
||||||
|
fedora-release-common.noarch 42-31 updates
|
||||||
|
fedora-release-container.noarch 42-31 updates
|
||||||
|
fedora-release-identity-container.noarch 42-31 updates
|
||||||
|
glibc.aarch64 2.41-18.fc42 updates
|
||||||
|
glibc-common.aarch64 2.41-18.fc42 updates
|
||||||
|
glibc-minimal-langpack.aarch64 2.41-18.fc42 updates
|
||||||
|
krb5-libs.aarch64 1.21.3-7.fc42 updates
|
||||||
|
libdnf5.aarch64 5.2.18.0-3.fc42 updates
|
||||||
|
libdnf5-cli.aarch64 5.2.18.0-3.fc42 updates
|
||||||
|
libsolv.aarch64 0.7.37-2.fc42 updates
|
||||||
|
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
|
||||||
|
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
|
||||||
|
tzdata.noarch 2026b-1.fc42 updates
|
||||||
|
vim-data.noarch 2:9.2.390-1.fc42 updates
|
||||||
|
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
krb5-libs.aarch64 1.21.3-7.fc42 updates
|
||||||
|
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
|
||||||
|
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
|
||||||
|
vim-data.noarch 2:9.2.390-1.fc42 updates
|
||||||
|
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
libpcap 1.10.7-1 -> 1.11.0-1
|
||||||
|
libsecret 0.21.7-1 -> 0.21.8.2-1
|
||||||
|
libtirpc 1.3.7-1 -> 1.3.8-1
|
||||||
|
tzdata 2026c-1 -> 2026d-1
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
|
||||||
|
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
|
||||||
|
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Repository | Name | Category | Severity | Interactive | Status | Summary
|
||||||
|
-------------------------------------------------------------+-----------------------------+----------+-----------+-------------+--------+--------------------------------
|
||||||
|
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3765 | security | moderate | --- | needed | Security update for openssl-1_1
|
||||||
|
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3926 | security | moderate | --- | needed | Security update for curl
|
||||||
|
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4078 | security | important | --- | needed | Security update for glib2
|
||||||
|
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4359 | security | moderate | --- | needed | Security update for curl
|
||||||
|
|
||||||
|
4 patches needed (4 security patches)
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
|
||||||
|
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
|
||||||
|
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
|
||||||
|
|
||||||
|
|
||||||
|
S | Repository | Name | Current Version | Available Version | Arch
|
||||||
|
---+--------------------------------------------------------------+--------------------+------------------------------------------+------------------------------------------+--------
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | aaa_base | 84.87+git20180409.04c9dae-150300.10.20.1 | 84.87+git20180409.04c9dae-150300.10.23.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | bash | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | bash-sh | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | crypto-policies | 20210917.c9d86d1-150400.3.6.1 | 20210917.c9d86d1-150400.3.8.1 | noarch
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | curl | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | glibc | 2.31-150300.86.3 | 2.31-150300.89.2 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libcom_err2 | 1.46.4-150400.3.6.2 | 1.46.4-150400.3.9.2 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libcurl4 | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libgcc_s1 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libglib-2_0-0 | 2.70.5-150400.3.14.1 | 2.70.5-150400.3.17.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1-hmac | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libreadline7 | 7.0-150400.25.22 | 7.0-150400.27.3.2 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools-base | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libstdc++6 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libudev1 | 249.17-150400.8.43.1 | 249.17-150400.8.46.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | libzypp | 17.35.8-150500.6.13.1 | 17.35.16-150500.6.31.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | login_defs | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | noarch
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | openssl-1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | shadow | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | aarch64
|
||||||
|
v | Update repository with updates from SUSE Linux Enterprise 15 | zypper | 1.14.76-150500.6.6.15 | 1.14.78-150500.6.14.1 | aarch64
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Connected Wi-Fi signal
|
||||||
|
|
||||||
|
Reports connected station interfaces only (no scans). `info.wf` holds the current
|
||||||
|
snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
|
||||||
|
`stats.wf` stores available RSSI as integer dBm. Collected on the default
|
||||||
|
interval only; real-time requests reuse the last snapshot.
|
||||||
|
|
||||||
|
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
|
||||||
|
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
|
||||||
|
- Windows: native WLAN API, keyed by interface GUID.
|
||||||
|
- Other platforms: unsupported.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Package wifi collects only currently associated station interfaces. Collection
|
||||||
|
// failures are empty snapshots, never cached connected state.
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
|
||||||
|
// text strings require UTF-8. Metadata must never invalidate the whole response.
|
||||||
|
func validSSID(ssid string) string {
|
||||||
|
if !utf8.ValidString(ssid) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ssid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect uses a single deadline across interface queries where supported.
|
||||||
|
// Unsupported platforms and denied association access produce no readings;
|
||||||
|
// later polls retry.
|
||||||
|
func Collect() map[string]system.WiFi {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return collect(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signals reduces a snapshot to the RSSI values stored in stats history.
|
||||||
|
// Interfaces without an available reading are omitted.
|
||||||
|
func Signals(snapshot map[string]system.WiFi) map[string]int8 {
|
||||||
|
var signals map[string]int8
|
||||||
|
for id, reading := range snapshot {
|
||||||
|
if reading.Signal == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if signals == nil {
|
||||||
|
signals = make(map[string]int8, len(snapshot))
|
||||||
|
}
|
||||||
|
signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8))
|
||||||
|
}
|
||||||
|
return signals
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
|
||||||
|
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
|
||||||
|
const coreWLANScript = `ObjC.import('CoreWLAN');
|
||||||
|
var result = {};
|
||||||
|
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
|
||||||
|
if (interfaces) {
|
||||||
|
for (var i = 0; i < interfaces.count; i++) {
|
||||||
|
var iface = interfaces.objectAtIndex(i);
|
||||||
|
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
|
||||||
|
var name = ObjC.unwrap(iface.interfaceName);
|
||||||
|
if (!name) continue;
|
||||||
|
var reading = {};
|
||||||
|
var ssid = ObjC.unwrap(iface.ssid);
|
||||||
|
if (ssid) reading.s = ssid;
|
||||||
|
var signal = Number(iface.rssiValue);
|
||||||
|
if (signal >= -150 && signal < 0) reading.r = signal;
|
||||||
|
result[name] = reading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON.stringify(result);`
|
||||||
|
|
||||||
|
func collect(ctx context.Context) map[string]system.WiFi {
|
||||||
|
cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||||
|
cmd.WaitDelay = 100 * time.Millisecond
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result map[string]system.WiFi
|
||||||
|
if json.Unmarshal(output, &result) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/mdlayher/genetlink"
|
||||||
|
"github.com/mdlayher/netlink"
|
||||||
|
native "github.com/mdlayher/wifi"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
type linuxClient interface {
|
||||||
|
Interfaces() ([]*native.Interface, error)
|
||||||
|
BSS(*native.Interface) (*native.BSS, error)
|
||||||
|
Station(*native.Interface, net.HardwareAddr) (*native.StationInfo, error)
|
||||||
|
SetDeadline(time.Time) error
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// nl80211Client adds a targeted GET_STATION request, as used by `iw link`.
|
||||||
|
// mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
|
||||||
|
// out-of-tree Realtek USB) answer with an empty list.
|
||||||
|
type nl80211Client struct {
|
||||||
|
*native.Client
|
||||||
|
conn *genetlink.Conn
|
||||||
|
family genetlink.Family
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNL80211Client() (*nl80211Client, error) {
|
||||||
|
client, err := native.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := genetlink.Dial(nil)
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &nl80211Client{Client: client, conn: conn, family: family}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nl80211Client) Station(ifi *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
|
||||||
|
ae := netlink.NewAttributeEncoder()
|
||||||
|
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifi.Index))
|
||||||
|
ae.Bytes(unix.NL80211_ATTR_MAC, mac)
|
||||||
|
data, err := ae.Encode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgs, err := c.conn.Execute(genetlink.Message{
|
||||||
|
Header: genetlink.Header{Command: unix.NL80211_CMD_GET_STATION, Version: c.family.Version},
|
||||||
|
Data: data,
|
||||||
|
}, c.family.ID, netlink.Request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(msgs) == 0 {
|
||||||
|
return nil, errors.New("no station info")
|
||||||
|
}
|
||||||
|
return native.ParseStationInfo(msgs[0].Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nl80211Client) SetDeadline(t time.Time) error {
|
||||||
|
return errors.Join(c.Client.SetDeadline(t), c.conn.SetDeadline(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nl80211Client) Close() error {
|
||||||
|
return errors.Join(c.conn.Close(), c.Client.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(ctx context.Context) map[string]system.WiFi {
|
||||||
|
client, err := newNL80211Client()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
return collectLinux(ctx, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
|
||||||
|
result := make(map[string]system.WiFi)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if client.SetDeadline(deadline) != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interfaces, err := client.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
|
||||||
|
// Only the explicit associated status proves a current connection.
|
||||||
|
bss, err := client.BSS(iface)
|
||||||
|
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reading := system.WiFi{SSID: validSSID(bss.SSID)}
|
||||||
|
// Station statistics may require permissions unavailable in default
|
||||||
|
// containers. Keep association even when RSSI cannot be read. Do not
|
||||||
|
// substitute cached scan signal, which may be arbitrarily old.
|
||||||
|
if len(bss.BSSID) > 0 {
|
||||||
|
if station, err := client.Station(iface, bss.BSSID); err == nil && station != nil {
|
||||||
|
signal := float64(station.Signal)
|
||||||
|
if signal >= -150 && signal < 0 {
|
||||||
|
reading.Signal = &signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[iface.Name] = reading
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
native "github.com/mdlayher/wifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeLinuxClient struct {
|
||||||
|
interfaces []*native.Interface
|
||||||
|
bss map[string]*native.BSS
|
||||||
|
stations map[string][]*native.StationInfo
|
||||||
|
interfacesErr, bssErr, stationErr, deadlineErr error
|
||||||
|
deadline time.Time
|
||||||
|
stationCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
|
||||||
|
return f.interfaces, f.interfacesErr
|
||||||
|
}
|
||||||
|
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
|
||||||
|
return f.bss[i.Name], f.bssErr
|
||||||
|
}
|
||||||
|
func (f *fakeLinuxClient) Station(i *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
|
||||||
|
f.stationCalls++
|
||||||
|
if f.stationErr != nil {
|
||||||
|
return nil, f.stationErr
|
||||||
|
}
|
||||||
|
for _, station := range f.stations[i.Name] {
|
||||||
|
if bytes.Equal(station.HardwareAddr, mac) {
|
||||||
|
return station, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("no such station")
|
||||||
|
}
|
||||||
|
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
|
||||||
|
func (f *fakeLinuxClient) Close() error { return nil }
|
||||||
|
|
||||||
|
func connectedClient() *fakeLinuxClient {
|
||||||
|
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
|
||||||
|
return &fakeLinuxClient{
|
||||||
|
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
|
||||||
|
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
|
||||||
|
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxSnapshots(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
modify func(*fakeLinuxClient)
|
||||||
|
want int
|
||||||
|
wantSignal bool
|
||||||
|
}{
|
||||||
|
{"connected", func(f *fakeLinuxClient) {}, 1, true},
|
||||||
|
{"multiple", func(f *fakeLinuxClient) {
|
||||||
|
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
|
||||||
|
f.bss["wlan1"] = f.bss["wlan0"]
|
||||||
|
}, 2, true},
|
||||||
|
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
|
||||||
|
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
|
||||||
|
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
|
||||||
|
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
|
||||||
|
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
|
||||||
|
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
|
||||||
|
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
|
||||||
|
{"station permission denied", func(f *fakeLinuxClient) {
|
||||||
|
f.stationErr = errors.New("permission denied")
|
||||||
|
f.bss["wlan0"].Signal = -4200
|
||||||
|
}, 1, false},
|
||||||
|
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
|
||||||
|
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
|
||||||
|
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
|
||||||
|
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
|
||||||
|
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
f := connectedClient()
|
||||||
|
tc.modify(f)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
got := collectLinux(ctx, f)
|
||||||
|
if len(got) != tc.want {
|
||||||
|
t.Fatalf("got %#v", got)
|
||||||
|
}
|
||||||
|
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
|
||||||
|
t.Fatalf("signal: %#v", got["wlan0"])
|
||||||
|
}
|
||||||
|
if tc.wantSignal && *got["wlan0"].Signal != -52 {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
if tc.want == 0 && f.stationCalls != 0 {
|
||||||
|
t.Fatal("queried station without association")
|
||||||
|
}
|
||||||
|
deadline, _ := ctx.Deadline()
|
||||||
|
if f.deadline != deadline {
|
||||||
|
t.Fatal("deadline not shared")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconnect(t *testing.T) {
|
||||||
|
f := connectedClient()
|
||||||
|
if len(collectLinux(context.Background(), f)) != 1 {
|
||||||
|
t.Fatal("initial")
|
||||||
|
}
|
||||||
|
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
|
||||||
|
if len(collectLinux(context.Background(), f)) != 0 {
|
||||||
|
t.Fatal("stale association")
|
||||||
|
}
|
||||||
|
f.bss["wlan0"].Status = native.BSSStatusAssociated
|
||||||
|
f.bss["wlan0"].SSID = "new"
|
||||||
|
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
|
||||||
|
t.Fatal("stale SSID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxInvalidSSID(t *testing.T) {
|
||||||
|
f := connectedClient()
|
||||||
|
f.bss["wlan0"].SSID = "raw\xff"
|
||||||
|
got := collectLinux(context.Background(), f)
|
||||||
|
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxCancelled(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
f := connectedClient()
|
||||||
|
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
|
||||||
|
t.Fatal("ignored cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSIDWireSafety(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ input, want string }{
|
||||||
|
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
|
||||||
|
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
ssid := validSSID(tc.input)
|
||||||
|
if ssid != tc.want {
|
||||||
|
t.Fatalf("got %q, want %q", ssid, tc.want)
|
||||||
|
}
|
||||||
|
signal := -50.0
|
||||||
|
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
|
||||||
|
wire, err := cbor.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded map[string]system.WiFi
|
||||||
|
if err := cbor.Unmarshal(wire, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
|
||||||
|
t.Fatal(decoded)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignals(t *testing.T) {
|
||||||
|
strong, weak, rounded := -40.0, -200.0, -52.6
|
||||||
|
got := Signals(map[string]system.WiFi{
|
||||||
|
"wlan0": {SSID: "home", Signal: &strong},
|
||||||
|
"wlan1": {Signal: &weak},
|
||||||
|
"wlan2": {Signal: &rounded},
|
||||||
|
"wlan3": {SSID: "no rssi"},
|
||||||
|
})
|
||||||
|
want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for id, signal := range want {
|
||||||
|
if got[id] != signal {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil {
|
||||||
|
t.Fatal("expected nil without available readings")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !linux && !windows && !darwin
|
||||||
|
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
func collect(context.Context) map[string]system.WiFi { return nil }
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package wifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
|
||||||
|
var wlanOpen = wlan.NewProc("WlanOpenHandle")
|
||||||
|
var wlanClose = wlan.NewProc("WlanCloseHandle")
|
||||||
|
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
|
||||||
|
var wlanQuery = wlan.NewProc("WlanQueryInterface")
|
||||||
|
var wlanFree = wlan.NewProc("WlanFreeMemory")
|
||||||
|
|
||||||
|
type wlanInterface struct {
|
||||||
|
GUID windows.GUID
|
||||||
|
Description [256]uint16
|
||||||
|
State uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type wlanConnection struct {
|
||||||
|
State uint32
|
||||||
|
Mode uint32
|
||||||
|
Profile [256]uint16
|
||||||
|
SSIDLength uint32
|
||||||
|
SSID [32]byte
|
||||||
|
// Only the prefix through DOT11_SSID is read.
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(ctx context.Context) map[string]system.WiFi {
|
||||||
|
result := make(map[string]system.WiFi)
|
||||||
|
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
|
||||||
|
if proc.Find() != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var handle windows.Handle
|
||||||
|
var version uint32
|
||||||
|
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer wlanClose.Call(uintptr(handle), 0)
|
||||||
|
var list unsafe.Pointer
|
||||||
|
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer wlanFree.Call(uintptr(list))
|
||||||
|
count := *(*uint32)(list)
|
||||||
|
if count > 1024 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if iface.State != 1 {
|
||||||
|
continue
|
||||||
|
} // wlan_interface_state_connected
|
||||||
|
reading := system.WiFi{}
|
||||||
|
// SSID access may be denied by location privacy policy. Association comes
|
||||||
|
// from the interface state, so missing SSID does not suppress valid RSSI.
|
||||||
|
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
|
||||||
|
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
|
||||||
|
connection := (*wlanConnection)(data)
|
||||||
|
if connection.State == 1 && connection.SSIDLength <= 32 {
|
||||||
|
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wlanFree.Call(uintptr(data))
|
||||||
|
}
|
||||||
|
// Native RSSI LONG, not the quality percentage in association attributes.
|
||||||
|
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
|
||||||
|
if size >= 4 {
|
||||||
|
signal := float64(*(*int32)(data))
|
||||||
|
if signal >= -150 && signal < 0 {
|
||||||
|
reading.Signal = &signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wlanFree.Call(uintptr(data))
|
||||||
|
}
|
||||||
|
result[iface.GUID.String()] = reading
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
|
||||||
|
var data unsafe.Pointer
|
||||||
|
var size uint32
|
||||||
|
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
return data, size
|
||||||
|
}
|
||||||
@@ -7,22 +7,26 @@ require (
|
|||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/ebitengine/purego v0.11.0
|
github.com/ebitengine/purego v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.3
|
github.com/fxamacker/cbor/v2 v2.9.4
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/lxzan/gws v1.10.1
|
github.com/lxzan/gws v1.10.2
|
||||||
github.com/nicholas-fedor/shoutrrr v0.20.0
|
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/opencontainers/go-digest v1.0.0
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.40.2
|
github.com/pocketbase/pocketbase v0.40.4
|
||||||
github.com/shirou/gopsutil/v4 v4.26.8
|
github.com/shirou/gopsutil/v4 v4.26.8
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
golang.org/x/crypto v0.56.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba
|
||||||
golang.org/x/net v0.58.0
|
golang.org/x/net v0.59.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
@@ -32,7 +36,7 @@ require (
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||||
github.com/eclipse/paho.golang v0.23.0 // indirect
|
github.com/eclipse/paho.golang v0.23.0 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
@@ -42,6 +46,7 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
@@ -49,20 +54,26 @@ require (
|
|||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/image v0.45.0 // indirect
|
golang.org/x/image v0.46.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
golang.org/x/sync v0.22.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/term v0.45.0 // indirect
|
golang.org/x/term v0.46.0 // indirect
|
||||||
golang.org/x/text v0.41.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
|
golang.org/x/tools v0.50.0 // indirect
|
||||||
|
mellium.im/reader v0.1.0 // indirect
|
||||||
|
mellium.im/sasl v0.3.2 // indirect
|
||||||
|
mellium.im/xmlstream v0.15.4 // indirect
|
||||||
|
mellium.im/xmpp v0.23.0 // indirect
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
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/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 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
||||||
@@ -31,8 +31,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
||||||
@@ -77,18 +77,26 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
github.com/lxzan/gws v1.10.2 h1:htReTvcY89iMk1ScVtUbk6J96kIZWaafj6r/lasK/NA=
|
||||||
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
github.com/lxzan/gws v1.10.2/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac=
|
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA=
|
github.com/nicholas-fedor/shoutrrr v0.21.0/go.mod h1:dgg4kJv9K0tLXBH/1TXiSibNbM2hcd4SK6xb0sglyU4=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||||
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
@@ -98,10 +106,10 @@ github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA
|
|||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
||||||
github.com/pocketbase/pocketbase v0.40.2 h1:7gTqvt3bmilkphyZZ1QNhX19g3BXHqT7ynDyU81RVT4=
|
github.com/pocketbase/pocketbase v0.40.4 h1:0SvSUreR3NhUMCs9LchE59oEG53efZ3cKiMGyAGBN9U=
|
||||||
github.com/pocketbase/pocketbase v0.40.2/go.mod h1:jc3YuyToy+ZXM4CeO7uSCN/htgR8yv+tjSE3eJZ8eh8=
|
github.com/pocketbase/pocketbase v0.40.4/go.mod h1:2mU+80FLiY1fb13WZRg8Xx/lKg4nTjgKVJMigyRH6k0=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
@@ -136,37 +144,37 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
|
||||||
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -176,6 +184,14 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
|
mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww=
|
||||||
|
mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI=
|
||||||
|
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
||||||
|
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||||
|
mellium.im/xmlstream v0.15.4 h1:gLKxcWl4rLMUpKgtzrTBvr4OexPeO/edYus+uK3F6ZI=
|
||||||
|
mellium.im/xmlstream v0.15.4/go.mod h1:yXaCW2++fmVO4L9piKVkyLDqnCmictVYF7FDQW8prb4=
|
||||||
|
mellium.im/xmpp v0.23.0 h1:rvKvOvMdIURCLaAWEJN8J0QpO3AJYCDKjxLsqtTPSjY=
|
||||||
|
mellium.im/xmpp v0.23.0/go.mod h1:GHDKlKKQe0LNmD9YqExyxnFEEBiz84KGqnfiA2VNzb8=
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
@@ -533,6 +534,20 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
|
|
||||||
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
|
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
|
||||||
scenarios = append(scenarios, beszelTests.ApiScenario{
|
scenarios = append(scenarios, beszelTests.ApiScenario{
|
||||||
|
BeforeTestFunc: func(tb testing.TB, _ *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
if !strings.HasPrefix(url, "mqtt://") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Keep the real MQTT rejection path, but advance its library's
|
||||||
|
// fixed timeout using virtual time instead of waiting 10 seconds.
|
||||||
|
e.Router.BindFunc(func(re *core.RequestEvent) error {
|
||||||
|
var err error
|
||||||
|
synctest.Test(tb.(*testing.T), func(t *testing.T) {
|
||||||
|
err = re.Next()
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
},
|
||||||
Name: "readonly cannot send to " + url,
|
Name: "readonly cannot send to " + url,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/beszel/test-notification",
|
URL: "/api/beszel/test-notification",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
|
||||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
@@ -178,39 +179,45 @@ func TestPublicNotificationTCP(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
// MQTT waits for a fixed library timeout even after a dial failure.
|
||||||
|
// Virtual time preserves the full send/cleanup path without that delay.
|
||||||
t.Run("internal destination", func(t *testing.T) {
|
t.Run("internal destination", func(t *testing.T) {
|
||||||
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
synctest.Test(t, func(t *testing.T) {
|
||||||
if !errors.Is(err, errInternalDestination) {
|
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
||||||
t.Fatalf("expected blocked destination, got %v", err)
|
if !errors.Is(err, errInternalDestination) {
|
||||||
}
|
t.Fatalf("expected blocked destination, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
||||||
var calls atomic.Int32
|
synctest.Test(t, func(t *testing.T) {
|
||||||
stopped := errors.New("test dial stopped")
|
var calls atomic.Int32
|
||||||
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
stopped := errors.New("test dial stopped")
|
||||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
||||||
calls.Add(1)
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
calls.Add(1)
|
||||||
t.Errorf("unexpected dial: %s %s", network, address)
|
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
||||||
}
|
t.Errorf("unexpected dial: %s %s", network, address)
|
||||||
if err := checkNotificationAddress(address); err != nil {
|
}
|
||||||
t.Error(err)
|
if err := checkNotificationAddress(address); err != nil {
|
||||||
}
|
t.Error(err)
|
||||||
return nil, stopped
|
}
|
||||||
},
|
return nil, stopped
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if closer, ok := service.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
if err := service.Send("test", &types.Params{}); err == nil {
|
||||||
|
t.Fatal("expected dial failure")
|
||||||
|
}
|
||||||
|
if calls.Load() == 0 {
|
||||||
|
t.Fatal("custom dialer was not used")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if closer, ok := service.(io.Closer); ok {
|
|
||||||
defer closer.Close()
|
|
||||||
}
|
|
||||||
if err := service.Send("test", &types.Params{}); err == nil {
|
|
||||||
t.Fatal("expected dial failure")
|
|
||||||
}
|
|
||||||
if calls.Load() == 0 {
|
|
||||||
t.Fatal("custom dialer was not used")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
@@ -14,6 +16,12 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type noKeyProvidedError struct{}
|
||||||
|
|
||||||
|
func (noKeyProvidedError) Error() string {
|
||||||
|
return "no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage"
|
||||||
|
}
|
||||||
|
|
||||||
// cli options
|
// cli options
|
||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
@@ -124,7 +132,7 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
|
|||||||
// Try key file
|
// Try key file
|
||||||
keyFile, ok := utils.GetEnv("KEY_FILE")
|
keyFile, ok := utils.GetEnv("KEY_FILE")
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
return nil, noKeyProvidedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, err := os.ReadFile(keyFile)
|
pubKey, err := os.ReadFile(keyFile)
|
||||||
@@ -138,6 +146,14 @@ func (opts *cmdOptions) getAddress() string {
|
|||||||
return agent.GetAddress(opts.listen)
|
return agent.GetAddress(opts.listen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isBenignStartupError(err error, goos string) bool {
|
||||||
|
if goos != "windows" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var noKeyErr noKeyProvidedError
|
||||||
|
return errors.As(err, &noKeyErr)
|
||||||
|
}
|
||||||
|
|
||||||
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
|
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
|
||||||
func handleFingerprint() {
|
func handleFingerprint() {
|
||||||
subCmd := ""
|
subCmd := ""
|
||||||
@@ -182,6 +198,12 @@ func main() {
|
|||||||
var err error
|
var err error
|
||||||
serverConfig.Keys, err = opts.loadPublicKeys()
|
serverConfig.Keys, err = opts.loadPublicKeys()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isBenignStartupError(err, runtime.GOOS) {
|
||||||
|
// WinGet launches the executable without configuration during validation.
|
||||||
|
// Exit successfully in that case while retaining the error on other platforms.
|
||||||
|
log.Print("Failed to load public keys:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Fatal("Failed to load public keys:", err)
|
log.Fatal("Failed to load public keys:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -187,6 +188,26 @@ func TestLoadPublicKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsBenignStartupError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
goos string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{name: "missing key on windows", err: noKeyProvidedError{}, goos: "windows", want: true},
|
||||||
|
{name: "wrapped missing key on windows", err: errors.Join(errors.New("startup failed"), noKeyProvidedError{}), goos: "windows", want: true},
|
||||||
|
{name: "missing key on linux", err: noKeyProvidedError{}, goos: "linux", want: false},
|
||||||
|
{name: "different error on windows", err: errors.New("invalid key"), goos: "windows", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, isBenignStartupError(tt.err, tt.goos))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetNetwork(t *testing.T) {
|
func TestGetNetwork(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN apk add --no-cache ca-certificates && update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: default scratch-based agent
|
# Final image: default scratch-based agent
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:3.23
|
FROM alpine:3.24
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image
|
# Final image
|
||||||
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:3.23
|
FROM alpine:3.24
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Smartmontools builder stage
|
# Smartmontools builder stage
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN set -eux; \
|
|||||||
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
||||||
export GOARM="${TARGETVARIANT#v}"; \
|
export GOARM="${TARGETVARIANT#v}"; \
|
||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
@@ -70,7 +70,9 @@ RUN set -eux; \
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# zfsutils-linux is distributed in Debian's contrib component.
|
||||||
|
RUN sed -i 's/Components: main/Components: main contrib/' /etc/apt/sources.list.d/debian.sources \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
zfsutils-linux \
|
zfsutils-linux \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ type Config struct {
|
|||||||
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
|
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
|
||||||
Port uint16 `cbor:"3,keyasint,omitempty"`
|
Port uint16 `cbor:"3,keyasint,omitempty"`
|
||||||
Interval uint16 `cbor:"4,keyasint"` // seconds
|
Interval uint16 `cbor:"4,keyasint"` // seconds
|
||||||
|
// Server is the DNS server to query (host or host:port, default port 53).
|
||||||
|
// Only used when Protocol is "dns"; empty means use the system resolver.
|
||||||
|
Server string `cbor:"5,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertInfo holds details of the leaf TLS certificate presented by a target.
|
||||||
|
type CertInfo struct {
|
||||||
|
// Expires is the certificate's NotAfter Unix timestamp in milliseconds.
|
||||||
|
Expires int64 `cbor:"0,keyasint" json:"expires"`
|
||||||
|
Issuer string `cbor:"1,keyasint,omitempty" json:"issuer,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncRequest defines an incremental or full monitor sync request sent to the agent.
|
// SyncRequest defines an incremental or full monitor sync request sent to the agent.
|
||||||
@@ -76,6 +86,8 @@ type Result struct {
|
|||||||
TotalCount int64 `cbor:"10,keyasint"`
|
TotalCount int64 `cbor:"10,keyasint"`
|
||||||
SuccessCount int64 `cbor:"11,keyasint"`
|
SuccessCount int64 `cbor:"11,keyasint"`
|
||||||
ResponseSum int64 `cbor:"12,keyasint"`
|
ResponseSum int64 `cbor:"12,keyasint"`
|
||||||
|
// Cert is set for HTTPS targets when a certificate check has new info the hub has not stored yet.
|
||||||
|
Cert *CertInfo `cbor:"13,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats holds response times in microseconds and packet loss percentage (0-100).
|
// Stats holds response times in microseconds and packet loss percentage (0-100).
|
||||||
|
|||||||
@@ -11,6 +11,14 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"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 {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
||||||
@@ -55,6 +63,7 @@ type Stats struct {
|
|||||||
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
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
|
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
|
DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
|
||||||
|
WiFi map[string]int8 `json:"wf,omitempty" cbor:"40,keyasint,omitempty"` // RSSI dBm keyed by interface; unavailable readings omitted
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,18 +119,17 @@ type GPUData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
Time time.Time `json:"-"`
|
Root bool `json:"-"`
|
||||||
Root bool `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Name string `json:"-"`
|
||||||
Name string `json:"-"`
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
|
||||||
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
|
||||||
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
|
||||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||||
@@ -184,6 +192,8 @@ type Info struct {
|
|||||||
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
||||||
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
|
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)
|
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
|
// Data that does not change during process lifetime and is not needed in All Systems table
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWiFiWireSnapshot(t *testing.T) {
|
||||||
|
signal := -55.0
|
||||||
|
for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} {
|
||||||
|
original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: make(map[string]int8, len(wifi))}}
|
||||||
|
for id := range wifi {
|
||||||
|
original.Stats.WiFi[id] = -55
|
||||||
|
}
|
||||||
|
encoded, err := cbor.Marshal(original)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var decoded CombinedData
|
||||||
|
if err = cbor.Unmarshal(encoded, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(decoded.Info.WiFi) != len(wifi) || len(decoded.Stats.WiFi) != len(wifi) {
|
||||||
|
t.Fatal(decoded)
|
||||||
|
}
|
||||||
|
encoded, err = json.Marshal(decoded.Info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var info map[string]any
|
||||||
|
if err = json.Unmarshal(encoded, &info); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := info["wf"]; ok != (len(wifi) > 0) {
|
||||||
|
t.Fatalf("wf present = %v for snapshot %v", ok, wifi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -978,11 +978,18 @@ func TestAgentWebSocketIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify system status
|
// A connected WebSocket does not mean the hub has finished verifying
|
||||||
updatedSystemRecord, err := testApp.FindRecordById("systems", systemRecord.Id)
|
// the agent and updating the system. Wait for the database state rather
|
||||||
require.NoError(t, err)
|
// than assuming that work completes within a fixed sleep under load.
|
||||||
status := updatedSystemRecord.GetString("status")
|
var status string
|
||||||
assert.Equal(t, tc.expectSystemStatus, status, "System status should match expected value")
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
updatedSystemRecord, err := testApp.FindRecordById("systems", systemRecord.Id)
|
||||||
|
if !assert.NoError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status = updatedSystemRecord.GetString("status")
|
||||||
|
assert.Equal(c, tc.expectSystemStatus, status, "System status should match expected value")
|
||||||
|
}, 5*time.Second, 20*time.Millisecond)
|
||||||
|
|
||||||
t.Logf("%s - System status: %s, Fingerprint: %s", tc.description, status, finalFingerprint)
|
t.Logf("%s - System status: %s, Fingerprint: %s", tc.description, status, finalFingerprint)
|
||||||
})
|
})
|
||||||
@@ -1142,42 +1149,43 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
|
|||||||
|
|
||||||
// Verify system creation/reuse behavior
|
// Verify system creation/reuse behavior
|
||||||
if tc.expectConnection {
|
if tc.expectConnection {
|
||||||
// Count systems after connection
|
expectedSystemsAfter := systemsBeforeCount
|
||||||
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemsAfterCount := len(systemsAfter)
|
|
||||||
|
|
||||||
if tc.expectNewSystem {
|
if tc.expectNewSystem {
|
||||||
// Should have created a new system
|
expectedSystemsAfter++
|
||||||
systemCount++
|
systemCount++
|
||||||
assert.Equal(t, systemsBeforeCount+1, systemsAfterCount, "Should have created a new system")
|
|
||||||
assert.Equal(t, systemCount, systemsAfterCount, "Total system count should match expected")
|
|
||||||
} else {
|
|
||||||
// Should have reused existing system
|
|
||||||
assert.Equal(t, systemsBeforeCount, systemsAfterCount, "Should not have created a new system")
|
|
||||||
assert.Equal(t, systemCount, systemsAfterCount, "Total system count should remain the same")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
// WebSocket connection precedes the hub's asynchronous system
|
||||||
|
// setup. Re-read all database state until setup is complete.
|
||||||
|
var systemId, status string
|
||||||
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
|
||||||
|
if !assert.NoError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Len(c, systemsAfter, expectedSystemsAfter, "System creation/reuse should match expected behavior")
|
||||||
|
assert.Len(c, systemsAfter, systemCount, "Total system count should match expected")
|
||||||
|
|
||||||
// Verify that a fingerprint record exists for this fingerprint
|
fingerprints, err := testApp.FindRecordsByFilter("fingerprints", "token = {:token} && fingerprint = {:fingerprint}", "", -1, 0, map[string]any{
|
||||||
fingerprints, err := testApp.FindRecordsByFilter("fingerprints", "token = {:token} && fingerprint = {:fingerprint}", "", -1, 0, map[string]any{
|
"token": universalToken,
|
||||||
"token": universalToken,
|
"fingerprint": tc.agentFingerprint,
|
||||||
"fingerprint": tc.agentFingerprint,
|
})
|
||||||
})
|
if !assert.NoError(c, err) || !assert.Len(c, fingerprints, 1, "Should have exactly one fingerprint record for this token+fingerprint combination") {
|
||||||
require.NoError(t, err)
|
return
|
||||||
require.Len(t, fingerprints, 1, "Should have exactly one fingerprint record for this token+fingerprint combination")
|
}
|
||||||
|
|
||||||
fingerprint := fingerprints[0]
|
fingerprint := fingerprints[0]
|
||||||
assert.Equal(t, universalToken, fingerprint.GetString("token"), "Fingerprint should have the universal token")
|
assert.Equal(c, universalToken, fingerprint.GetString("token"), "Fingerprint should have the universal token")
|
||||||
assert.Equal(t, tc.agentFingerprint, fingerprint.GetString("fingerprint"), "Fingerprint should match agent's fingerprint")
|
assert.Equal(c, tc.agentFingerprint, fingerprint.GetString("fingerprint"), "Fingerprint should match agent's fingerprint")
|
||||||
|
|
||||||
// Verify system status
|
systemId = fingerprint.GetString("system")
|
||||||
systemId := fingerprint.GetString("system")
|
system, err := testApp.FindRecordById("systems", systemId)
|
||||||
system, err := testApp.FindRecordById("systems", systemId)
|
if !assert.NoError(c, err) {
|
||||||
require.NoError(t, err)
|
return
|
||||||
status := system.GetString("status")
|
}
|
||||||
assert.Equal(t, tc.expectSystemStatus, status, "System status should match expected value")
|
status = system.GetString("status")
|
||||||
|
assert.Equal(c, tc.expectSystemStatus, status, "System status should match expected value")
|
||||||
|
}, 5*time.Second, 20*time.Millisecond)
|
||||||
|
|
||||||
t.Logf("%s - System ID: %s, Status: %s, New System: %v", tc.description, systemId, status, tc.expectNewSystem)
|
t.Logf("%s - System ID: %s, Status: %s, New System: %v", tc.description, systemId, status, tc.expectNewSystem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (h *Hub) StartHub() error {
|
|||||||
|
|
||||||
// TODO: move to users package
|
// TODO: move to users package
|
||||||
// handle default values for user / user_settings creation
|
// handle default values for user / user_settings creation
|
||||||
|
h.App.OnRecordAuthWithOAuth2Request("users").BindFunc(h.um.InitializeOAuthUserRole)
|
||||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ func generateMonitorID(systemId string, config monitor.Config) string {
|
|||||||
if config.Protocol == "tcp" {
|
if config.Protocol == "tcp" {
|
||||||
args = append(args, strconv.FormatUint(uint64(config.Port), 10))
|
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...)
|
return systems.MakeStableHashId(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +57,15 @@ func bindNetworkMonitorsEvents(hub *Hub) {
|
|||||||
// record with the new ID and delete the old one. Otherwise, just update the existing monitor on the agent.
|
// 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 {
|
hub.OnRecordUpdateRequest("network_monitors").BindFunc(func(e *core.RecordRequestEvent) error {
|
||||||
systemID := e.Record.GetString("system")
|
systemID := e.Record.GetString("system")
|
||||||
|
protocol := e.Record.GetString("protocol")
|
||||||
// only tcp uses port - set other protocols port to zero
|
// only tcp uses port - set other protocols port to zero
|
||||||
if e.Record.GetString("protocol") != "tcp" {
|
if protocol != "tcp" {
|
||||||
e.Record.Set("port", 0)
|
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))
|
ID := generateMonitorID(systemID, *monitorConfigFromRecord(e.Record))
|
||||||
if ID != e.Record.Id {
|
if ID != e.Record.Id {
|
||||||
newRecord := copyMonitorToNewRecord(e.Record, ID)
|
newRecord := copyMonitorToNewRecord(e.Record, ID)
|
||||||
@@ -103,6 +112,7 @@ func monitorConfigFromRecord(record *core.Record) *monitor.Config {
|
|||||||
Protocol: record.GetString("protocol"),
|
Protocol: record.GetString("protocol"),
|
||||||
Port: uint16(record.GetInt("port")),
|
Port: uint16(record.GetInt("port")),
|
||||||
Interval: uint16(record.GetInt("interval")),
|
Interval: uint16(record.GetInt("interval")),
|
||||||
|
Server: record.GetString("server"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +124,9 @@ func setMonitorResultFields(record *core.Record, result monitor.Result) {
|
|||||||
record.Set("resMin1h", result.MinResponse1h)
|
record.Set("resMin1h", result.MinResponse1h)
|
||||||
record.Set("resMax1h", result.MaxResponse1h)
|
record.Set("resMax1h", result.MaxResponse1h)
|
||||||
record.Set("loss1h", result.PacketLoss1h)
|
record.Set("loss1h", result.PacketLoss1h)
|
||||||
|
if result.Cert != nil {
|
||||||
|
record.Set("certInfo", result.Cert)
|
||||||
|
}
|
||||||
record.Set("updated", nowString)
|
record.Set("updated", nowString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +137,7 @@ func copyMonitorToNewRecord(oldRecord *core.Record, newID string) *core.Record {
|
|||||||
collection := oldRecord.Collection()
|
collection := oldRecord.Collection()
|
||||||
newRecord := core.NewRecord(collection)
|
newRecord := core.NewRecord(collection)
|
||||||
newRecord.Id = newID
|
newRecord.Id = newID
|
||||||
fields := []string{"system", "target", "protocol", "port", "interval", "enabled"}
|
fields := []string{"system", "target", "protocol", "port", "server", "interval", "enabled"}
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
newRecord.Set(field, oldRecord.Get(field))
|
newRecord.Set(field, oldRecord.Get(field))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,39 @@ func TestGenerateMonitorID(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "84167969",
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -199,6 +232,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
|||||||
"target": "https://example.com",
|
"target": "https://example.com",
|
||||||
"protocol": "http",
|
"protocol": "http",
|
||||||
"port": 443,
|
"port": 443,
|
||||||
|
"server": "1.1.1.1",
|
||||||
"interval": 60,
|
"interval": 60,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"res": 1200,
|
"res": 1200,
|
||||||
@@ -206,6 +240,7 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
|||||||
"resMin1h": 900,
|
"resMin1h": 900,
|
||||||
"resMax1h": 1600,
|
"resMax1h": 1600,
|
||||||
"loss1h": 5,
|
"loss1h": 5,
|
||||||
|
"certInfo": map[string]any{"expires": 1800000000000},
|
||||||
"updated": "2026-04-29 12:00:00.000Z",
|
"updated": "2026-04-29 12:00:00.000Z",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,7 +250,9 @@ func TestCopyMonitorToNewRecordDropsResultFields(t *testing.T) {
|
|||||||
assert.Equal(t, "https://example.com", newRecord.GetString("target"))
|
assert.Equal(t, "https://example.com", newRecord.GetString("target"))
|
||||||
assert.Equal(t, "http", newRecord.GetString("protocol"))
|
assert.Equal(t, "http", newRecord.GetString("protocol"))
|
||||||
assert.Equal(t, 443, newRecord.GetInt("port"))
|
assert.Equal(t, 443, newRecord.GetInt("port"))
|
||||||
|
assert.Equal(t, "1.1.1.1", newRecord.GetString("server"))
|
||||||
assert.True(t, newRecord.GetBool("enabled"))
|
assert.True(t, newRecord.GetBool("enabled"))
|
||||||
|
assert.Contains(t, []string{"", "null"}, newRecord.GetString("certInfo"))
|
||||||
assert.Zero(t, newRecord.GetFloat("res"))
|
assert.Zero(t, newRecord.GetFloat("res"))
|
||||||
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
|
assert.Zero(t, newRecord.GetFloat("resAvg1h"))
|
||||||
assert.Zero(t, newRecord.GetFloat("resMin1h"))
|
assert.Zero(t, newRecord.GetFloat("resMin1h"))
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
|
esystem "github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHNetworkMonitorReconnectSync(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
sys.manager.zfsFetchMap = expirymap.New[zfsFetchState](time.Hour)
|
||||||
|
t.Cleanup(sys.manager.zfsFetchMap.StopCleaner)
|
||||||
|
sys.ctx = context.Background()
|
||||||
|
sys.Status = up
|
||||||
|
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
signer, err := ssh.NewSignerFromKey(key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
config := &ssh.ServerConfig{NoClientAuth: true, ServerVersion: "SSH-2.0-beszel_0.20.0"}
|
||||||
|
config.AddHostKey(signer)
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
sys.Host, sys.Port, err = net.SplitHostPort(listener.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
sys.manager.sshConfig = &ssh.ClientConfig{User: "test", HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: time.Second}
|
||||||
|
t.Cleanup(sys.closeSSHConnection)
|
||||||
|
requests := make(chan monitor.SyncRequest, 10)
|
||||||
|
var failSync atomic.Bool
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
server, channels, reqs, err := ssh.NewServerConn(conn, config)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
for channel := range channels {
|
||||||
|
ch, reqs, err := channel.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer ch.Close()
|
||||||
|
for req := range reqs {
|
||||||
|
if req.Type != "shell" {
|
||||||
|
_ = req.Reply(false, nil)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = req.Reply(true, nil)
|
||||||
|
var request common.HubRequest[cbor.RawMessage]
|
||||||
|
if cbor.NewDecoder(ch).Decode(&request) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response := common.AgentResponse{}
|
||||||
|
switch request.Action {
|
||||||
|
case common.GetData:
|
||||||
|
response.SystemData = &esystem.CombinedData{}
|
||||||
|
case common.SyncNetworkMonitors:
|
||||||
|
var syncReq monitor.SyncRequest
|
||||||
|
if cbor.Unmarshal(request.Data, &syncReq) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requests <- syncReq
|
||||||
|
if failSync.Load() {
|
||||||
|
response.Error = "test sync failure"
|
||||||
|
} else {
|
||||||
|
response.Data, _ = cbor.Marshal(monitor.SyncResponse{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = cbor.NewEncoder(ch).Encode(response)
|
||||||
|
_, _ = ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
collection, err := app.FindCachedCollectionByNameOrId("network_monitors")
|
||||||
|
require.NoError(t, err)
|
||||||
|
probe := core.NewRecord(collection)
|
||||||
|
probe.Load(map[string]any{"system": sys.Id, "target": "localhost", "protocol": "tcp", "port": 80, "interval": 60, "enabled": true})
|
||||||
|
require.NoError(t, app.SaveNoValidate(probe))
|
||||||
|
fetch := func() {
|
||||||
|
t.Helper()
|
||||||
|
_, err := sys.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.NoError(t, err, "monitor sync failure must not fail stats fetching")
|
||||||
|
}
|
||||||
|
receive := func() monitor.SyncRequest {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case req := <-requests:
|
||||||
|
require.Equal(t, monitor.SyncActionReplace, req.Action)
|
||||||
|
return req
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("missing full monitor sync")
|
||||||
|
return monitor.SyncRequest{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetch()
|
||||||
|
require.Equal(t, probe.Id, receive().Configs[0].ID)
|
||||||
|
require.False(t, sys.monitorsNeedSync.Load())
|
||||||
|
fetch()
|
||||||
|
require.Empty(t, requests, "steady-state fetch must not resync")
|
||||||
|
|
||||||
|
// Simulate loss of the agent process/connection and its in-memory monitors.
|
||||||
|
require.NoError(t, sys.client.Load().Close())
|
||||||
|
fetch()
|
||||||
|
require.Equal(t, probe.Id, receive().Configs[0].ID)
|
||||||
|
require.False(t, sys.monitorsNeedSync.Load())
|
||||||
|
|
||||||
|
// Failed replacements are retried on the next successful stats fetch.
|
||||||
|
require.NoError(t, sys.client.Load().Close())
|
||||||
|
failSync.Store(true)
|
||||||
|
fetch()
|
||||||
|
receive()
|
||||||
|
require.True(t, sys.monitorsNeedSync.Load())
|
||||||
|
failSync.Store(false)
|
||||||
|
fetch()
|
||||||
|
receive()
|
||||||
|
require.False(t, sys.monitorsNeedSync.Load())
|
||||||
|
|
||||||
|
probe.Set("enabled", false)
|
||||||
|
require.NoError(t, app.SaveNoValidate(probe))
|
||||||
|
require.NoError(t, sys.client.Load().Close())
|
||||||
|
fetch()
|
||||||
|
require.Empty(t, receive().Configs, "empty replacement must clear stale monitors")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingNetworkMonitorSyncQueryFailure(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
_, err := app.DB().NewQuery("DROP TABLE network_monitors").Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sys.monitorsNeedSync.Store(true)
|
||||||
|
sys.syncPendingNetworkMonitors()
|
||||||
|
require.True(t, sys.monitorsNeedSync.Load())
|
||||||
|
}
|
||||||
@@ -222,3 +222,49 @@ func TestNetworkMonitorAlertsAfterCommit(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNetworkMonitorCertPersistence(t *testing.T) {
|
||||||
|
for _, realtime := range []bool{false, true} {
|
||||||
|
name := "sql"
|
||||||
|
if realtime {
|
||||||
|
name = "realtime"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
if realtime {
|
||||||
|
client := subscriptions.NewDefaultClient()
|
||||||
|
client.Subscribe("network_monitors/*")
|
||||||
|
app.SubscriptionsBroker().Register(client)
|
||||||
|
t.Cleanup(func() { app.SubscriptionsBroker().Unregister(client.Id()) })
|
||||||
|
}
|
||||||
|
col, err := app.FindCachedCollectionByNameOrId("network_monitors")
|
||||||
|
require.NoError(t, err)
|
||||||
|
record := core.NewRecord(col)
|
||||||
|
record.Id = "monitor1"
|
||||||
|
record.Set("system", sys.Id)
|
||||||
|
require.NoError(t, app.SaveNoValidate(record))
|
||||||
|
|
||||||
|
storedCert := func() monitor.CertInfo {
|
||||||
|
t.Helper()
|
||||||
|
record, err := app.FindRecordById("network_monitors", "monitor1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var cert monitor.CertInfo
|
||||||
|
require.NoError(t, record.UnmarshalJSONField("certInfo", &cert))
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
cert := &monitor.CertInfo{Expires: 1_800_000_000_000, Issuer: "Test CA"}
|
||||||
|
_, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{
|
||||||
|
"monitor1": {LastProbeAt: 1000, Cert: cert},
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, *cert, storedCert())
|
||||||
|
|
||||||
|
// Results without cert info keep the stored certificate.
|
||||||
|
_, err = sys.createRecords(&system.CombinedData{Monitors: map[string]monitor.Result{
|
||||||
|
"monitor1": {LastProbeAt: 2000},
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, *cert, storedCert())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
|
esystem "github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -22,17 +24,31 @@ import (
|
|||||||
type monitorSyncClient struct {
|
type monitorSyncClient struct {
|
||||||
gws.BuiltinEventHandler
|
gws.BuiltinEventHandler
|
||||||
requests chan common.HubRequest[monitor.SyncRequest]
|
requests chan common.HubRequest[monitor.SyncRequest]
|
||||||
|
failSync atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *monitorSyncClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
func (c *monitorSyncClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||||
defer message.Close()
|
defer message.Close()
|
||||||
var req common.HubRequest[monitor.SyncRequest]
|
var req common.HubRequest[cbor.RawMessage]
|
||||||
if err := cbor.Unmarshal(message.Bytes(), &req); err != nil {
|
if err := cbor.Unmarshal(message.Bytes(), &req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.requests <- req
|
resp := common.AgentResponse{Id: req.Id}
|
||||||
data, _ := cbor.Marshal(monitor.SyncResponse{})
|
if req.Action == common.GetData {
|
||||||
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, Data: data})
|
resp.SystemData = &esystem.CombinedData{}
|
||||||
|
} else {
|
||||||
|
var data monitor.SyncRequest
|
||||||
|
if err := cbor.Unmarshal(req.Data, &data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.requests <- common.HubRequest[monitor.SyncRequest]{Id: req.Id, Action: req.Action, Data: data}
|
||||||
|
if c.failSync.Load() {
|
||||||
|
resp.Error = "test sync failure"
|
||||||
|
} else {
|
||||||
|
resp.Data, _ = cbor.Marshal(monitor.SyncResponse{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response, _ := cbor.Marshal(resp)
|
||||||
_ = conn.WriteMessage(gws.OpcodeBinary, response)
|
_ = conn.WriteMessage(gws.OpcodeBinary, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +73,7 @@ func TestNetworkMonitorSyncSkipsOlderAgents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkMonitorReconnectSync(t *testing.T) {
|
func TestNetworkMonitorReconnectSync(t *testing.T) {
|
||||||
for _, change := range []string{"delete", "disable"} {
|
for _, change := range []string{"delete", "disable", "retry"} {
|
||||||
t.Run(change, func(t *testing.T) {
|
t.Run(change, func(t *testing.T) {
|
||||||
sys, app := newTestSystemWithHub(t)
|
sys, app := newTestSystemWithHub(t)
|
||||||
record, err := app.FindRecordById("systems", sys.Id)
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
@@ -120,9 +136,33 @@ func TestNetworkMonitorReconnectSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.failSync.Store(change == "retry")
|
||||||
initial := connect()
|
initial := connect()
|
||||||
require.Len(t, initial.Configs, 1)
|
require.Len(t, initial.Configs, 1)
|
||||||
require.Equal(t, probe.Id, initial.Configs[0].ID)
|
require.Equal(t, probe.Id, initial.Configs[0].ID)
|
||||||
|
if change == "retry" {
|
||||||
|
system, err := sm.GetSystem(sys.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Eventually(t, system.monitorsNeedSync.Load, time.Second, time.Millisecond)
|
||||||
|
// A second failed sync must not fail the stats fetch or clear pending state.
|
||||||
|
_, err = system.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, system.monitorsNeedSync.Load())
|
||||||
|
require.Len(t, client.requests, 1)
|
||||||
|
<-client.requests
|
||||||
|
client.failSync.Store(false)
|
||||||
|
_, err = system.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, system.monitorsNeedSync.Load())
|
||||||
|
require.Len(t, client.requests, 1)
|
||||||
|
retry := <-client.requests
|
||||||
|
require.Equal(t, monitor.SyncActionReplace, retry.Data.Action)
|
||||||
|
require.Equal(t, initial.Configs, retry.Data.Configs)
|
||||||
|
_, err = system.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, client.requests, "successful sync must not repeat on every fetch")
|
||||||
|
return
|
||||||
|
}
|
||||||
require.NoError(t, sm.RemoveSystem(sys.Id))
|
require.NoError(t, sm.RemoveSystem(sys.Id))
|
||||||
if change == "delete" {
|
if change == "delete" {
|
||||||
require.NoError(t, app.Delete(probe))
|
require.NoError(t, app.Delete(probe))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package systems
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
@@ -9,6 +10,27 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// syncPendingNetworkMonitors runs on WebSocket connect and after successful stats
|
||||||
|
// fetches. Failed syncs retry on the next update without taking the system down.
|
||||||
|
func (sys *System) syncPendingNetworkMonitors() {
|
||||||
|
if !sys.monitorsNeedSync.Swap(false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sys.syncAllNetworkMonitors(); err != nil {
|
||||||
|
sys.monitorsNeedSync.Store(true)
|
||||||
|
sys.manager.hub.Logger().Warn("failed to sync monitors to agent", "system", sys.Id, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sys *System) syncAllNetworkMonitors() error {
|
||||||
|
configs, err := sys.manager.GetMonitorConfigsForSystem(sys.Id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load monitors: %w", err)
|
||||||
|
}
|
||||||
|
// An empty set must also replace probes retained across a disconnect.
|
||||||
|
return sys.SyncNetworkMonitors(configs)
|
||||||
|
}
|
||||||
|
|
||||||
// SyncNetworkMonitors sends monitor configurations to the agent.
|
// SyncNetworkMonitors sends monitor configurations to the agent.
|
||||||
func (sys *System) SyncNetworkMonitors(configs []monitor.Config) error {
|
func (sys *System) SyncNetworkMonitors(configs []monitor.Config) error {
|
||||||
_, err := sys.syncNetworkMonitors(monitor.SyncRequest{Action: monitor.SyncActionReplace, Configs: configs})
|
_, err := sys.syncNetworkMonitors(monitor.SyncRequest{Action: monitor.SyncActionReplace, Configs: configs})
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ type System struct {
|
|||||||
smartInterval time.Duration // Interval for periodic SMART data updates
|
smartInterval time.Duration // Interval for periodic SMART data updates
|
||||||
zfsFetching atomic.Bool // True if ZFS pools are currently being fetched
|
zfsFetching atomic.Bool // True if ZFS pools are currently being fetched
|
||||||
zfsInterval time.Duration // Interval for periodic ZFS detail data updates
|
zfsInterval time.Duration // Interval for periodic ZFS detail data updates
|
||||||
|
|
||||||
|
// A fresh connection needs a full monitor configuration sync.
|
||||||
|
monitorsNeedSync atomic.Bool
|
||||||
// Serialize persistence from scheduled updates and resumes through commit.
|
// Serialize persistence from scheduled updates and resumes through commit.
|
||||||
recordsMu sync.Mutex
|
recordsMu sync.Mutex
|
||||||
// Protected by recordsMu; realtime reads don't consume probes.
|
// Protected by recordsMu; realtime reads don't consume probes.
|
||||||
@@ -276,6 +279,10 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
|
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
|
||||||
return err
|
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 {
|
if data.Monitors != nil {
|
||||||
@@ -430,6 +437,8 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map
|
|||||||
for i, f := range monitorFields {
|
for i, f := range monitorFields {
|
||||||
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
|
setClauses[i] = fmt.Sprintf("%s={:%s}", f, f)
|
||||||
}
|
}
|
||||||
|
// Results omit certInfo unless it changed, so keep the stored value.
|
||||||
|
setClauses = append(setClauses, "certInfo=COALESCE({:certInfo}, certInfo)")
|
||||||
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", monitorCollectionName, strings.Join(setClauses, ", "))
|
queryString := fmt.Sprintf("UPDATE %s SET %s WHERE id={:id}", monitorCollectionName, strings.Join(setClauses, ", "))
|
||||||
updateQuery = db.NewQuery(queryString)
|
updateQuery = db.NewQuery(queryString)
|
||||||
}
|
}
|
||||||
@@ -450,11 +459,23 @@ func (sys *System) updateNetworkMonitorsRecords(app core.App, monitorResults map
|
|||||||
var record *core.Record
|
var record *core.Record
|
||||||
record, err = app.FindRecordById(monitorCollectionName, id)
|
record, err = app.FindRecordById(monitorCollectionName, id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
if result.Cert != nil {
|
||||||
|
monitorData["certInfo"] = result.Cert
|
||||||
|
}
|
||||||
record.Load(monitorData)
|
record.Load(monitorData)
|
||||||
err = app.SaveNoValidate(record)
|
err = app.SaveNoValidate(record)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
_, err = updateQuery.Bind(dbx.Params(monitorData)).Execute()
|
monitorData["certInfo"] = nil
|
||||||
|
if result.Cert != nil {
|
||||||
|
var cert []byte
|
||||||
|
if cert, err = json.Marshal(result.Cert); err == nil {
|
||||||
|
monitorData["certInfo"] = string(cert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
_, err = updateQuery.Bind(dbx.Params(monitorData)).Execute()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Warn("Failed to update monitor", "system", systemId, "monitor", id, "err", err)
|
app.Logger().Warn("Failed to update monitor", "system", systemId, "monitor", id, "err", err)
|
||||||
@@ -630,7 +651,10 @@ func (sys *System) request(ctx context.Context, action common.WebSocketAction, r
|
|||||||
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
|
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
|
||||||
// Keep legacy SSH client/version fields in sync for other code paths.
|
// Keep legacy SSH client/version fields in sync for other code paths.
|
||||||
if sys.sshTransport != nil {
|
if sys.sshTransport != nil {
|
||||||
sys.client.Store(sys.sshTransport.GetClient())
|
client := sys.sshTransport.GetClient()
|
||||||
|
if previous := sys.client.Swap(client); client != nil && client != previous {
|
||||||
|
sys.monitorsNeedSync.Store(true)
|
||||||
|
}
|
||||||
sys.agentVersion = sys.sshTransport.GetAgentVersion()
|
sys.agentVersion = sys.sshTransport.GetAgentVersion()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -688,8 +712,14 @@ func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*syste
|
|||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
wsData, err := sys.fetchDataViaWebSocket(options)
|
wsData, err := sys.fetchDataViaWebSocket(options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
sys.syncPendingNetworkMonitors()
|
||||||
return wsData, nil
|
return wsData, nil
|
||||||
}
|
}
|
||||||
|
// A slow collection doesn't mean the connection is broken. Closing it
|
||||||
|
// would force the agent into a reconnect loop, so only report the error.
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// close the WebSocket connection if error and try SSH
|
// close the WebSocket connection if error and try SSH
|
||||||
sys.closeWebSocketConnection()
|
sys.closeWebSocketConnection()
|
||||||
}
|
}
|
||||||
@@ -698,15 +728,23 @@ func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*syste
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
sys.syncPendingNetworkMonitors()
|
||||||
return sshData, nil
|
return sshData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wsDataRequestTimeout bounds how long to wait for stats over WebSocket. Agent
|
||||||
|
// collection can legitimately take several seconds (e.g. a slow `zpool list`),
|
||||||
|
// so this must be well above the request manager's 5s default.
|
||||||
|
var wsDataRequestTimeout = 30 * time.Second
|
||||||
|
|
||||||
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||||
return nil, errors.New("no websocket connection")
|
return nil, errors.New("no websocket connection")
|
||||||
}
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), wsDataRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
||||||
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
|
err := wsTransport.Request(ctx, common.GetData, options, sys.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -932,6 +970,7 @@ func (s *System) createSSHClient() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
|
s.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
|
||||||
|
s.monitorsNeedSync.Store(true)
|
||||||
s.manager.resetFailedSmartFetchState(s.Id)
|
s.manager.resetFailedSmartFetchState(s.Id)
|
||||||
s.manager.resetFailedZfsFetchState(s.Id)
|
s.manager.resetFailedZfsFetchState(s.Id)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
|
|||||||
// It clears system info when the status is changed to paused.
|
// It clears system info when the status is changed to paused.
|
||||||
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
|
||||||
if e.Record.GetString("status") == paused {
|
if e.Record.GetString("status") == paused {
|
||||||
e.Record.Set("info", system.Info{})
|
var prevInfo system.Info
|
||||||
|
e.Record.UnmarshalJSONField("info", &prevInfo)
|
||||||
|
e.Record.Set("info", system.Info{AgentVersion: prevInfo.AgentVersion})
|
||||||
}
|
}
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
@@ -217,11 +219,15 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
// Pause monitoring but keep system in manager for potential resume
|
// Pause monitoring but keep system in manager for potential resume
|
||||||
system.closeSSHConnection()
|
system.closeSSHConnection()
|
||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
_ = deactivateAlerts(e.App, e.Record.Id, false)
|
||||||
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||||
sm.hub.CancelPendingContainerAlerts(e.Record.Id)
|
sm.hub.CancelPendingContainerAlerts(e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case pending:
|
case pending:
|
||||||
|
// Keep an active status alert until connectivity is confirmed. This lets
|
||||||
|
// pending -> up resolve it and send the recovery notification after a
|
||||||
|
// system address or other connection setting is changed.
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id, true)
|
||||||
// Resume monitoring, preferring existing WebSocket connection
|
// Resume monitoring, preferring existing WebSocket connection
|
||||||
if ok && system.WsConn != nil {
|
if ok && system.WsConn != nil {
|
||||||
go system.update()
|
go system.update()
|
||||||
@@ -231,7 +237,6 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
if err := sm.AddRecord(e.Record, nil); err != nil {
|
if err := sm.AddRecord(e.Record, nil); err != nil {
|
||||||
e.App.Logger().Error("Error adding record", "err", err)
|
e.App.Logger().Error("Error adding record", "err", err)
|
||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case down:
|
case down:
|
||||||
// Docker state is unknown while the system is unreachable. Do not let a
|
// Docker state is unknown while the system is unreachable. Do not let a
|
||||||
@@ -254,8 +259,9 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger status change alerts for up/down transitions
|
// A connection-setting update moves a down system through pending before it
|
||||||
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) {
|
// comes up, so recover active status alerts on any non-up -> up transition.
|
||||||
|
if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus != up) {
|
||||||
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
|
if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil {
|
||||||
e.App.Logger().Error("Error handling status alerts", "err", err)
|
e.App.Logger().Error("Error handling status alerts", "err", err)
|
||||||
}
|
}
|
||||||
@@ -349,23 +355,14 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
system := sm.NewSystem(systemId)
|
system := sm.NewSystem(systemId)
|
||||||
system.WsConn = wsConn
|
system.WsConn = wsConn
|
||||||
system.agentVersion = agentVersion
|
system.agentVersion = agentVersion
|
||||||
|
system.monitorsNeedSync.Store(true)
|
||||||
|
|
||||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync network monitors to the newly connected agent
|
// Sync network monitors to the newly connected agent
|
||||||
go func() {
|
go system.syncPendingNetworkMonitors()
|
||||||
configs, err := sm.GetMonitorConfigsForSystem(systemId)
|
|
||||||
if err != nil {
|
|
||||||
sm.hub.Logger().Warn("failed to load monitors for agent", "system", systemId, "err", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// An empty set must also replace any probes retained across a disconnect.
|
|
||||||
if err := system.SyncNetworkMonitors(configs); err != nil {
|
|
||||||
sm.hub.Logger().Warn("failed to sync monitors to agent", "system", systemId, "err", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -422,10 +419,11 @@ func (sm *SystemManager) createSSHClientConfig() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deactivateAlerts finds all triggered alerts for a system and sets them to inactive.
|
// deactivateAlerts finds triggered alerts for a system and sets them to inactive.
|
||||||
// This is called when a system is paused or goes offline to prevent continued alerts.
|
// Status alerts can be preserved while connection changes are pending so that a
|
||||||
|
// confirmed recovery still produces an "up" notification.
|
||||||
// Monitor incidents remain open: a missing observation does not establish recovery.
|
// Monitor incidents remain open: a missing observation does not establish recovery.
|
||||||
func deactivateAlerts(app core.App, systemID string) error {
|
func deactivateAlerts(app core.App, systemID string, preserveStatusAlert bool) error {
|
||||||
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
|
// Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API
|
||||||
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
|
// _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).Execute()
|
||||||
|
|
||||||
@@ -435,6 +433,9 @@ func deactivateAlerts(app core.App, systemID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
|
if preserveStatusAlert && alert.GetString("name") == "Status" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
alert.Set("triggered", false)
|
alert.Set("triggered", false)
|
||||||
if err := app.SaveNoValidate(alert); err != nil {
|
if err := app.SaveNoValidate(alert); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateRecordsSyncSystemNames(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env string
|
||||||
|
hostname string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"disabled", "", "new-host", "test-system"},
|
||||||
|
{"enabled", "true", "new-host", "new-host"},
|
||||||
|
{"enabled with empty hostname", "true", "", "test-system"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("SYNC_SYSTEM_NAMES", tc.env)
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
_, err := sys.createRecords(&system.CombinedData{Details: &system.Details{Hostname: tc.hostname}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tc.expected, record.GetString("name"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateRecordsWiFiDisconnectReconnect(t *testing.T) {
|
||||||
|
sys, app := newTestSystemWithHub(t)
|
||||||
|
signal := -50.0
|
||||||
|
for _, snapshot := range []map[string]system.WiFi{
|
||||||
|
{"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {SSID: "other"}},
|
||||||
|
{}, nil,
|
||||||
|
{"wlan0": {SSID: "new", Signal: &signal}},
|
||||||
|
} {
|
||||||
|
_, err := sys.createRecords(&system.CombinedData{Info: system.Info{WiFi: snapshot}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var info system.Info
|
||||||
|
require.NoError(t, record.UnmarshalJSONField("info", &info))
|
||||||
|
require.Len(t, info.WiFi, len(snapshot), "current info must replace previous connection state")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,38 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPauseSystemPreservesAgentVersion(t *testing.T) {
|
||||||
|
hub, user := tests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "pause-info-test",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
record.Set("info", system.Info{
|
||||||
|
AgentVersion: "0.20.0",
|
||||||
|
Cpu: 42.5,
|
||||||
|
MemPct: 60,
|
||||||
|
Uptime: 3600,
|
||||||
|
Services: []uint16{3, 1},
|
||||||
|
})
|
||||||
|
require.NoError(t, hub.Save(record))
|
||||||
|
|
||||||
|
record.Set("status", "paused")
|
||||||
|
require.NoError(t, hub.Save(record))
|
||||||
|
|
||||||
|
pausedRecord, err := hub.FindRecordById("systems", record.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "paused", pausedRecord.GetString("status"))
|
||||||
|
var info system.Info
|
||||||
|
require.NoError(t, pausedRecord.UnmarshalJSONField("info", &info))
|
||||||
|
assert.Equal(t, system.Info{AgentVersion: "0.20.0"}, info)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSystemManagerNew(t *testing.T) {
|
func TestSystemManagerNew(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -133,6 +165,55 @@ func TestSystemManagerNew(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatusAlertRecoveryAfterPendingTransition(t *testing.T) {
|
||||||
|
hub, user := tests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
userSettings.Set("settings", map[string]any{
|
||||||
|
"emails": []string{"test@example.com"},
|
||||||
|
"webhooks": []string{},
|
||||||
|
})
|
||||||
|
require.NoError(t, hub.Save(userSettings))
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "changed-address",
|
||||||
|
"host": "192.0.2.1",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.Set("status", "down")
|
||||||
|
require.NoError(t, hub.Save(record))
|
||||||
|
|
||||||
|
alert, err := tests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": record.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
"triggered": true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
|
||||||
|
// The edit dialog temporarily moves the system through pending. The active
|
||||||
|
// status alert must remain active until the new connection is confirmed.
|
||||||
|
record.Set("host", "192.0.2.2")
|
||||||
|
record.Set("status", "pending")
|
||||||
|
require.NoError(t, hub.Save(record))
|
||||||
|
alert, err = hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, alert.GetBool("triggered"), "pending connection update should preserve the active status alert")
|
||||||
|
|
||||||
|
record.Set("status", "up")
|
||||||
|
require.NoError(t, hub.Save(record))
|
||||||
|
alert, err = hub.FindRecordById("alerts", alert.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, alert.GetBool("triggered"), "pending -> up should resolve the active status alert")
|
||||||
|
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "recovery should send an up notification")
|
||||||
|
}
|
||||||
|
|
||||||
func testOld(t *testing.T, hub *tests.TestHub) {
|
func testOld(t *testing.T, hub *tests.TestHub) {
|
||||||
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
|
user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
esystem "github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// slowDataClient answers GetData only after release is closed, simulating an
|
||||||
|
// agent whose collection outlasts the hub's request timeout.
|
||||||
|
type slowDataClient struct {
|
||||||
|
gws.BuiltinEventHandler
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *slowDataClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||||
|
defer message.Close()
|
||||||
|
var req common.HubRequest[cbor.RawMessage]
|
||||||
|
if err := cbor.Unmarshal(message.Bytes(), &req); err != nil || req.Action != common.GetData {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
<-c.release
|
||||||
|
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, SystemData: &esystem.CombinedData{}})
|
||||||
|
_ = conn.WriteMessage(gws.OpcodeBinary, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchDataTimeoutKeepsWebSocketOpen(t *testing.T) {
|
||||||
|
originalTimeout := wsDataRequestTimeout
|
||||||
|
wsDataRequestTimeout = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { wsDataRequestTimeout = originalTimeout })
|
||||||
|
|
||||||
|
connections := make(chan *ws.WsConn, 1)
|
||||||
|
upgrader := gws.NewUpgrader(&monitorSyncServer{}, nil)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wsConn := ws.NewWsConnection(conn, semver.MustParse("0.20.0"))
|
||||||
|
conn.Session().Store("wsConn", wsConn)
|
||||||
|
connections <- wsConn
|
||||||
|
conn.ReadLoop()
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
client := &slowDataClient{release: make(chan struct{})}
|
||||||
|
conn, _, err := gws.NewClient(client, &gws.ClientOption{Addr: "ws" + strings.TrimPrefix(server.URL, "http")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = conn.NetConn().Close() })
|
||||||
|
go conn.ReadLoop()
|
||||||
|
|
||||||
|
var sys *System
|
||||||
|
select {
|
||||||
|
case wsConn := <-connections:
|
||||||
|
sys = &System{WsConn: wsConn}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("websocket connection was not established")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
require.True(t, sys.WsConn.IsConnected(), "a slow collection must not close the connection")
|
||||||
|
|
||||||
|
// The late response is discarded and the next request still succeeds.
|
||||||
|
close(client.release)
|
||||||
|
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
@@ -35,6 +35,14 @@ func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction,
|
|||||||
}
|
}
|
||||||
// Try generic Data field first (0.19+)
|
// Try generic Data field first (0.19+)
|
||||||
if len(resp.Data) > 0 {
|
if len(resp.Data) > 0 {
|
||||||
|
// Wi-Fi maps are complete snapshots. CBOR otherwise merges entries into
|
||||||
|
// reused destinations, retaining disconnected interfaces and old RSSI.
|
||||||
|
if action == common.GetData {
|
||||||
|
if data, ok := dest.(*system.CombinedData); ok {
|
||||||
|
data.Info.WiFi = nil
|
||||||
|
data.Stats.WiFi = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
|
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
|
||||||
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
|
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package transport
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWiFiSequentialResponseSnapshots(t *testing.T) {
|
||||||
|
signal := -50.0
|
||||||
|
var decoded system.CombinedData
|
||||||
|
for _, snapshot := range []map[string]system.WiFi{
|
||||||
|
{"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {Signal: &signal}},
|
||||||
|
{"wlan0": {SSID: "home"}}, {}, nil,
|
||||||
|
{"wlan1": {SSID: "new", Signal: &signal}},
|
||||||
|
} {
|
||||||
|
signals := make(map[string]int8)
|
||||||
|
for id, reading := range snapshot {
|
||||||
|
if reading.Signal != nil {
|
||||||
|
signals[id] = int8(*reading.Signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := cbor.Marshal(system.CombinedData{Info: system.Info{WiFi: snapshot}, Stats: system.Stats{WiFi: signals}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded))
|
||||||
|
require.Len(t, decoded.Info.WiFi, len(snapshot))
|
||||||
|
require.Len(t, decoded.Stats.WiFi, len(signals))
|
||||||
|
for id, want := range snapshot {
|
||||||
|
require.Equal(t, want, decoded.Info.WiFi[id])
|
||||||
|
}
|
||||||
|
for id, want := range signals {
|
||||||
|
require.Equal(t, want, decoded.Stats.WiFi[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An older generic-response agent may omit both fields entirely.
|
||||||
|
payload, err := cbor.Marshal(map[int]any{0: map[int]any{}, 1: map[int]any{}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, UnmarshalResponse(common.AgentResponse{Data: payload}, common.GetData, &decoded))
|
||||||
|
require.Empty(t, decoded.Info.WiFi)
|
||||||
|
require.Empty(t, decoded.Stats.WiFi)
|
||||||
|
}
|
||||||
+21
-19
@@ -58,36 +58,38 @@ func Update(cmd *cobra.Command, _ []string) {
|
|||||||
func restartService() {
|
func restartService() {
|
||||||
// Check if we're running as a service by looking for systemd
|
// Check if we're running as a service by looking for systemd
|
||||||
if _, err := exec.LookPath("systemctl"); err == nil {
|
if _, err := exec.LookPath("systemctl"); err == nil {
|
||||||
// Check if beszel service exists and is active
|
// install-hub.sh names the unit beszel-hub.service. beszel.service is
|
||||||
cmd := exec.Command("systemctl", "is-active", "beszel.service")
|
// kept as a fallback for hand written units.
|
||||||
if err := cmd.Run(); err == nil {
|
for _, unit := range []string{"beszel-hub.service", "beszel.service"} {
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
if err := exec.Command("systemctl", "is-active", unit).Run(); err != nil {
|
||||||
restartCmd := exec.Command("systemctl", "restart", "beszel.service")
|
continue
|
||||||
if err := restartCmd.Run(); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel")
|
|
||||||
} else {
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
|
||||||
}
|
}
|
||||||
|
reportRestart(exec.Command("systemctl", "restart", unit), "sudo systemctl restart "+unit)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for OpenRC (Alpine Linux)
|
// Check for OpenRC (Alpine Linux)
|
||||||
if _, err := exec.LookPath("rc-service"); err == nil {
|
if _, err := exec.LookPath("rc-service"); err == nil {
|
||||||
cmd := exec.Command("rc-service", "beszel", "status")
|
for _, service := range []string{"beszel-hub", "beszel"} {
|
||||||
if err := cmd.Run(); err == nil {
|
if err := exec.Command("rc-service", service, "status").Run(); err != nil {
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
continue
|
||||||
restartCmd := exec.Command("rc-service", "beszel", "restart")
|
|
||||||
if err := restartCmd.Run(); err != nil {
|
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart")
|
|
||||||
} else {
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
|
||||||
}
|
}
|
||||||
|
reportRestart(exec.Command("rc-service", service, "restart"), "sudo rc-service "+service+" restart")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reportRestart runs the restart command and prints the result.
|
||||||
|
func reportRestart(cmd *exec.Cmd, manualCommand string) {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...")
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err)
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: "+manualCommand)
|
||||||
|
} else {
|
||||||
|
ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+9
@@ -1057,6 +1057,15 @@ func init() {
|
|||||||
"required": true,
|
"required": true,
|
||||||
"system": false,
|
"system": false,
|
||||||
"type": "number"
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool2084032502",
|
||||||
|
"name": "updatable",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
c, err := app.FindCollectionByNameOrId("zfs_pools")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.Fields.Add(&core.TextField{Name: "display_name"})
|
|
||||||
c.Fields.Add(&core.BoolField{Name: "raw"})
|
|
||||||
return app.Save(c)
|
|
||||||
}, func(app core.App) error {
|
|
||||||
c, err := app.FindCollectionByNameOrId("zfs_pools")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.Fields.RemoveByName("display_name")
|
|
||||||
c.Fields.RemoveByName("raw")
|
|
||||||
|
|
||||||
return app.Save(c)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+4
-4
@@ -7,18 +7,18 @@ import (
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
m.Register(func(app core.App) error {
|
m.Register(func(app core.App) error {
|
||||||
collection, err := app.FindCollectionByNameOrId("containers")
|
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collection.Fields.Add(&core.BoolField{Name: "updatable"})
|
collection.Fields.Add(&core.JSONField{Name: "certInfo"})
|
||||||
return app.Save(collection)
|
return app.Save(collection)
|
||||||
}, func(app core.App) error {
|
}, func(app core.App) error {
|
||||||
collection, err := app.FindCollectionByNameOrId("containers")
|
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
collection.Fields.RemoveByName("updatable")
|
collection.Fields.RemoveByName("certInfo")
|
||||||
return app.Save(collection)
|
return app.Save(collection)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Fields.Add(&core.TextField{Id: "nm_server", Name: "server", Max: 260})
|
||||||
|
return app.Save(collection)
|
||||||
|
}, func(app core.App) error {
|
||||||
|
collection, err := app.FindCollectionByNameOrId("network_monitors")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collection.Fields.RemoveByName("server")
|
||||||
|
return app.Save(collection)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -267,6 +267,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wifiSums := make(map[string]int)
|
||||||
|
wifiCounts := make(map[string]int)
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
batteryCount := 0
|
batteryCount := 0
|
||||||
@@ -285,6 +287,10 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for i := range records {
|
for i := range records {
|
||||||
stats := &records[i]
|
stats := &records[i]
|
||||||
|
for id, signal := range stats.WiFi {
|
||||||
|
wifiSums[id] += int(signal)
|
||||||
|
wifiCounts[id]++
|
||||||
|
}
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
// accumulate cpu time breakdowns if present
|
||||||
@@ -614,6 +620,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
|||||||
sum.CpuBreakdown = avg
|
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
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWiFiAverageAvailableSamples(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{WiFi: map[string]int8{"wlan0": -40}},
|
||||||
|
{},
|
||||||
|
{WiFi: map[string]int8{"wlan0": -61, "wlan1": -80}},
|
||||||
|
}
|
||||||
|
result := AverageSystemStatsSlice(input)
|
||||||
|
if len(result.WiFi) != 2 || result.WiFi["wlan0"] != -51 || result.WiFi["wlan1"] != -80 {
|
||||||
|
t.Fatalf("%#v", result.WiFi)
|
||||||
|
}
|
||||||
|
if input[0].WiFi["wlan0"] != -40 {
|
||||||
|
t.Fatal("mutated input")
|
||||||
|
}
|
||||||
|
if len(AverageSystemStatsSlice([]system.Stats{{}, {}}).WiFi) != 0 {
|
||||||
|
t.Fatal("invented wifi")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,6 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemTranslation = t`System`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
||||||
@@ -129,9 +127,9 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
||||||
{system ? (
|
{system ? (
|
||||||
<Trans>Edit {{ foo: systemTranslation }}</Trans>
|
<Trans>Edit System</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add System</Trans>
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
@@ -268,9 +266,9 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
{/* Save */}
|
{/* Save */}
|
||||||
<Button>
|
<Button>
|
||||||
{system ? (
|
{system ? (
|
||||||
<Trans>Save {{ foo: systemTranslation }}</Trans>
|
<Trans>Save System</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add System</Trans>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
|||||||
const allSystems = $allSystemsById.get()
|
const allSystems = $allSystemsById.get()
|
||||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||||
return systemNameA.localeCompare(systemNameB)
|
const primary = systemNameA.localeCompare(systemNameB)
|
||||||
|
if (primary !== 0) {
|
||||||
|
return primary
|
||||||
|
}
|
||||||
|
return a.original.name.localeCompare(b.original.name)
|
||||||
},
|
},
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
@@ -192,12 +196,12 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
className="shrink-0 rounded-sm text-emerald-600 dark:text-emerald-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="shrink-0 rounded-sm text-emerald-600 dark:text-emerald-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
aria-label={t`Image update available`}
|
aria-label={t({ message: "Image update available", context: "Docker image" })}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<CircleArrowUpIcon className="size-4" aria-hidden="true" />
|
<CircleArrowUpIcon className="size-4" aria-hidden="true" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{t`Image update available`}</TooltipContent>
|
<TooltipContent>{t({ message: "Image update available", context: "Docker image" })}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
@@ -48,8 +47,6 @@ export default function Navbar() {
|
|||||||
|
|
||||||
const AdminLinks = AdminDropdownGroup()
|
const AdminLinks = AdminDropdownGroup()
|
||||||
|
|
||||||
const systemTranslation = t`System`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
@@ -140,7 +137,7 @@ export default function Navbar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add System</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
@@ -243,7 +240,7 @@ export default function Navbar() {
|
|||||||
{!isReadOnlyUser() && (
|
{!isReadOnlyUser() && (
|
||||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<Trans>Add System</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -261,7 +258,7 @@ function AdminDropdownGroup() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<a href={prependBasePath("/_/")} target="_blank">
|
<a href={prependBasePath("/_/#/collections?collection=users")} target="_blank">
|
||||||
<UsersIcon className="me-2.5 h-4 w-4" />
|
<UsersIcon className="me-2.5 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Users</Trans>
|
<Trans>Users</Trans>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { ChevronDownIcon, ListIcon, SearchIcon, ServerIcon } from "lucide-react"
|
import { ChevronDownIcon, ListIcon, PlusIcon, SearchIcon, ServerIcon } from "lucide-react"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { cn, supportsNetworkMonitors } from "@/lib/utils"
|
import { cn, supportsNetworkMonitors } from "@/lib/utils"
|
||||||
@@ -37,6 +37,7 @@ type MonitorValues = {
|
|||||||
target: string
|
target: string
|
||||||
protocol: MonitorProtocol
|
protocol: MonitorProtocol
|
||||||
port: number
|
port: number
|
||||||
|
server: string
|
||||||
interval: string
|
interval: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ type NormalizedMonitorValues = Omit<MonitorValues, "system" | "interval"> & {
|
|||||||
interval: number
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval">
|
type BulkMonitorLineSource = Pick<NetworkMonitorRecord, "target" | "protocol" | "port" | "interval" | "server">
|
||||||
|
|
||||||
const defaultInterval = 30
|
const defaultInterval = 30
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ const NormalizedMonitorValuesSchema = v.pipe(
|
|||||||
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
|
target: v.pipe(v.string(), v.trim(), v.nonEmpty("target is required")),
|
||||||
protocol: MonitorProtocolSchema,
|
protocol: MonitorProtocolSchema,
|
||||||
port: v.number(),
|
port: v.number(),
|
||||||
|
server: v.pipe(v.string(), v.trim()),
|
||||||
interval: MonitorIntervalSchema,
|
interval: MonitorIntervalSchema,
|
||||||
}),
|
}),
|
||||||
v.transform((input): NormalizedMonitorValues => {
|
v.transform((input): NormalizedMonitorValues => {
|
||||||
@@ -78,6 +80,8 @@ const NormalizedMonitorValuesSchema = v.pipe(
|
|||||||
target: protocol === "http" ? httpTarget : input.target,
|
target: protocol === "http" ? httpTarget : input.target,
|
||||||
protocol,
|
protocol,
|
||||||
port,
|
port,
|
||||||
|
// Only DNS monitors use a custom server; clear it for other protocols.
|
||||||
|
server: protocol === "dns" ? input.server : "",
|
||||||
interval: input.interval,
|
interval: input.interval,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -100,6 +104,7 @@ const BulkMonitorSchema = v.object({
|
|||||||
protocol: v.optional(v.pipe(v.string(), v.trim())),
|
protocol: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
port: 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())),
|
interval: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
|
server: v.optional(v.pipe(v.string(), v.trim())),
|
||||||
})
|
})
|
||||||
|
|
||||||
function normalizeHttpTarget(target: string, port = 0) {
|
function normalizeHttpTarget(target: string, port = 0) {
|
||||||
@@ -152,18 +157,19 @@ function buildMonitorPayload(values: MonitorValues, enabled = true) {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port">
|
type MonitorIdentity = Pick<MonitorValues, "system" | "target" | "protocol" | "port" | "server">
|
||||||
function getMonitorIdentityKey({ system, target, protocol, port }: MonitorIdentity) {
|
function getMonitorIdentityKey({ system, target, protocol, port, server }: MonitorIdentity) {
|
||||||
return `${system}${target}${protocol}${port}`
|
return `${system}${target}${protocol}${port}${protocol === "dns" ? server : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
|
function parseBulkMonitorLine(line: string, lineNumber: number, system: string) {
|
||||||
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = ""] = line.split(",")
|
const [rawTarget = "", rawProtocol = "", rawPort = "", rawInterval = "", rawServer = ""] = line.split(",")
|
||||||
const parsed = v.safeParse(BulkMonitorSchema, {
|
const parsed = v.safeParse(BulkMonitorSchema, {
|
||||||
target: rawTarget,
|
target: rawTarget,
|
||||||
protocol: rawProtocol,
|
protocol: rawProtocol,
|
||||||
port: rawPort,
|
port: rawPort,
|
||||||
interval: rawInterval,
|
interval: rawInterval,
|
||||||
|
server: rawServer,
|
||||||
})
|
})
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid monitor entry"}`)
|
throw new Error(`Line ${lineNumber}: ${parsed.issues[0]?.message || "invalid monitor entry"}`)
|
||||||
@@ -176,6 +182,7 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
|
|||||||
target: parsed.output.target,
|
target: parsed.output.target,
|
||||||
protocol,
|
protocol,
|
||||||
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
port: parsed.output.port ? Number(parsed.output.port) : 0,
|
||||||
|
server: parsed.output.server || "",
|
||||||
interval: parsed.output.interval || `${defaultInterval}`,
|
interval: parsed.output.interval || `${defaultInterval}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -183,7 +190,8 @@ function parseBulkMonitorLine(line: string, lineNumber: number, system: string)
|
|||||||
export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) {
|
export function formatBulkMonitorLine(monitor: BulkMonitorLineSource) {
|
||||||
const port = monitor.protocol !== "tcp" || monitor.port === 443 ? "" : `${monitor.port}`
|
const port = monitor.protocol !== "tcp" || monitor.port === 443 ? "" : `${monitor.port}`
|
||||||
const interval = monitor.interval === defaultInterval ? "" : `${monitor.interval}`
|
const interval = monitor.interval === defaultInterval ? "" : `${monitor.interval}`
|
||||||
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval]).join(",")
|
const server = monitor.protocol !== "dns" ? "" : monitor.server
|
||||||
|
return trimTrailingEmptyFields([monitor.target, monitor.protocol, port, interval, server]).join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
function SystemMultiSelect({
|
function SystemMultiSelect({
|
||||||
@@ -242,7 +250,7 @@ function SystemMultiSelect({
|
|||||||
? t`Select systems`
|
? t`Select systems`
|
||||||
: selectedSystemIds.size === 1
|
: selectedSystemIds.size === 1
|
||||||
? systems.find((s) => selectedSystemIds.has(s.id))?.name
|
? systems.find((s) => selectedSystemIds.has(s.id))?.name
|
||||||
: t`${selectedSystemIds.size} systems selected`}
|
: t`${selectedSystemIds.size} selected`}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" />
|
<ChevronDownIcon className="size-4 absolute end-4 top-1/2 -translate-y-1/2 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -353,6 +361,8 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
|
|||||||
const bulkFormRef = useRef<HTMLFormElement>(null)
|
const bulkFormRef = useRef<HTMLFormElement>(null)
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
const systems = useStore($systems)
|
||||||
|
const hasEligibleSystems = systemId ? true : systems.some(supportsNetworkMonitors)
|
||||||
|
|
||||||
const resetBulkForm = () => {
|
const resetBulkForm = () => {
|
||||||
setBulkInput("")
|
setBulkInput("")
|
||||||
@@ -443,14 +453,24 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-0 rounded-lg">
|
<div className="flex gap-0 rounded-lg">
|
||||||
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow">
|
<Button variant="outline" onClick={openAdd} className="rounded-e-none grow" disabled={!hasEligibleSystems}>
|
||||||
{/* <PlusIcon className="size-4 me-1" /> */}
|
<PlusIcon className="size-4 me-1" />
|
||||||
<Trans>Add {{ foo: t`Monitor` }}</Trans>
|
<span className="sm:hidden">
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
<Trans>Add {{ foo: t`Monitor` }}</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-full bg-muted"></div>
|
<div className="w-px h-full bg-muted"></div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline" className="px-2 rounded-s-none border-s-0" aria-label={t`More monitor actions`}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="px-2 rounded-s-none border-s-0"
|
||||||
|
aria-label={`More actions`}
|
||||||
|
disabled={!hasEligibleSystems}
|
||||||
|
>
|
||||||
<ChevronDownIcon className="size-4" />
|
<ChevronDownIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -485,7 +505,9 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
|
|||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
<Trans>Bulk Add {{ foo: t`Network Monitors` }}</Trans>
|
<Trans>Bulk Add {{ foo: t`Network Monitors` }}</Trans>
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription>target[,protocol[,port[,interval]]]</SheetDescription>
|
<SheetDescription>
|
||||||
|
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
|
||||||
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
|
<form ref={bulkFormRef} onSubmit={handleBulkSubmit} className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex-1 flex flex-col space-y-4 overflow-auto p-4">
|
<div className="flex-1 flex flex-col space-y-4 overflow-auto p-4">
|
||||||
@@ -518,10 +540,17 @@ export function AddMonitorDialog({ systemId, monitors }: { systemId?: string; mo
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="font-mono grow text-sm bg-card"
|
className="font-mono grow text-sm bg-card"
|
||||||
placeholder={["1.1.1.1", "example.com,tcp", "https://example.com,http,,60"].join("\n")}
|
placeholder={[
|
||||||
|
"1.1.1.1",
|
||||||
|
"example.com,tcp",
|
||||||
|
"https://example.com,http,,60",
|
||||||
|
"example.com,dns,,,1.1.1.1",
|
||||||
|
].join("\n")}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">target[,protocol[,port[,interval]]]</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<Trans>target[,protocol[,port[,interval[,server]]]]</Trans>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className="border-t">
|
<SheetFooter className="border-t">
|
||||||
@@ -575,6 +604,7 @@ function MonitorDialogContent({
|
|||||||
const [protocol, setProtocol] = useState<MonitorProtocol>(monitor?.protocol ?? "icmp")
|
const [protocol, setProtocol] = useState<MonitorProtocol>(monitor?.protocol ?? "icmp")
|
||||||
const [target, setTarget] = useState(monitor?.target ?? "")
|
const [target, setTarget] = useState(monitor?.target ?? "")
|
||||||
const [port, setPort] = useState(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
|
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 [monitorInterval, setMonitorInterval] = useState(String(monitor?.interval ?? defaultInterval))
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
|
const [selectedSystemId, setSelectedSystemId] = useState(monitor?.system ?? "")
|
||||||
@@ -593,6 +623,7 @@ function MonitorDialogContent({
|
|||||||
setProtocol(monitor?.protocol ?? "icmp")
|
setProtocol(monitor?.protocol ?? "icmp")
|
||||||
setTarget(monitor?.target ?? "")
|
setTarget(monitor?.target ?? "")
|
||||||
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
|
setPort(monitor?.protocol === "tcp" && monitor.port ? String(monitor.port) : "")
|
||||||
|
setServer(monitor?.protocol === "dns" ? (monitor.server ?? "") : "")
|
||||||
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
|
setMonitorInterval(String(monitor?.interval ?? defaultInterval))
|
||||||
setSelectedSystemId(monitor?.system ?? "")
|
setSelectedSystemId(monitor?.system ?? "")
|
||||||
setSelectedSystemIds(new Set())
|
setSelectedSystemIds(new Set())
|
||||||
@@ -613,6 +644,7 @@ function MonitorDialogContent({
|
|||||||
target,
|
target,
|
||||||
protocol,
|
protocol,
|
||||||
port: protocol === "tcp" ? Number(port) : 0,
|
port: protocol === "tcp" ? Number(port) : 0,
|
||||||
|
server: protocol === "dns" ? server.trim() : "",
|
||||||
interval: monitorInterval,
|
interval: monitorInterval,
|
||||||
},
|
},
|
||||||
monitor ? monitor.enabled : true
|
monitor ? monitor.enabled : true
|
||||||
@@ -693,7 +725,7 @@ function MonitorDialogContent({
|
|||||||
<Input
|
<Input
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value)}
|
onChange={(e) => setTarget(e.target.value)}
|
||||||
placeholder={protocol === "http" ? "http://localhost:8090" : "1.1.1.1"}
|
placeholder={protocol === "http" ? "http://localhost:8090" : protocol === "dns" ? "example.com" : "1.1.1.1"}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -729,6 +761,21 @@ function MonitorDialogContent({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>
|
<Label>
|
||||||
<Trans>Interval (seconds)</Trans>
|
<Trans>Interval (seconds)</Trans>
|
||||||
@@ -759,13 +806,7 @@ function MonitorDialogContent({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))}
|
disabled={loading || (!systemId && (isEditing ? !selectedSystemId : !selectedSystemIds.size))}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{isEditing ? (
|
||||||
isEditing ? (
|
|
||||||
<Trans>Saving...</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Creating...</Trans>
|
|
||||||
)
|
|
||||||
) : isEditing ? (
|
|
||||||
<Trans>Save {{ foo: t`Monitor` }}</Trans>
|
<Trans>Save {{ foo: t`Monitor` }}</Trans>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Add {{ foo: t`Monitor` }}</Trans>
|
<Trans>Add {{ foo: t`Monitor` }}</Trans>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
CopyPlusIcon,
|
CopyPlusIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
|
import type { NetworkMonitorRecord, SystemRecord } from "@/types"
|
||||||
@@ -29,17 +30,26 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
import { $allSystemsById, $longestSystemName } from "@/lib/stores"
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
|
import type { ReadableAtom } from "nanostores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { SystemStatus } from "@/lib/enums"
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
|
import { formatBulkMonitorLine } from "@/components/network-monitors-table/monitor-dialog"
|
||||||
import { Badge } from "../ui/badge"
|
import { Badge } from "../ui/badge"
|
||||||
import { getMonitorTarget } from "@/lib/network-monitor-utils"
|
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
|
|
||||||
|
const certExpiryDotColors = { ok: "bg-green-500", warning: "bg-yellow-500", critical: "bg-red-500" }
|
||||||
|
|
||||||
|
declare module "@tanstack/react-table" {
|
||||||
|
interface ColumnMeta<TData, TValue> {
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const protocolColors: Record<string, string> = {
|
const protocolColors: Record<string, string> = {
|
||||||
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
|
icmp: "bg-blue-500/15! text-blue-600 dark:text-blue-400",
|
||||||
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
|
tcp: "bg-purple-500/15! text-purple-600 dark:text-purple-400",
|
||||||
@@ -62,6 +72,7 @@ const isMuted = (record: NetworkMonitorRecord, systemRecord: SystemRecord | unde
|
|||||||
|
|
||||||
export function getMonitorColumns(
|
export function getMonitorColumns(
|
||||||
longestTarget = "",
|
longestTarget = "",
|
||||||
|
$longestSystemName: ReadableAtom<string>,
|
||||||
{
|
{
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -98,6 +109,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
|
meta: { label: t`System` },
|
||||||
accessorFn: (record) => record.system,
|
accessorFn: (record) => record.system,
|
||||||
sortingFn: (a, b) => {
|
sortingFn: (a, b) => {
|
||||||
const allSystems = $allSystemsById.get()
|
const allSystems = $allSystemsById.get()
|
||||||
@@ -128,12 +140,13 @@ export function getMonitorColumns(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[status, name]
|
[status, name, longestSystemName]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
|
meta: { label: t`Target` },
|
||||||
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
||||||
accessorFn: (record) => getMonitorTarget(record),
|
accessorFn: (record) => getMonitorTarget(record),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||||
@@ -146,6 +159,8 @@ export function getMonitorColumns(
|
|||||||
color = "bg-primary/40"
|
color = "bg-primary/40"
|
||||||
} else if (status === SystemStatus.Down || status === SystemStatus.Pending) {
|
} else if (status === SystemStatus.Down || status === SystemStatus.Pending) {
|
||||||
color = "bg-yellow-500"
|
color = "bg-yellow-500"
|
||||||
|
} else if (monitor.updated && !monitor.res) {
|
||||||
|
color = "bg-red-500"
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="ms-1.5 max-w-64 flex gap-2 items-center tabular-nums">
|
<div className="ms-1.5 max-w-64 flex gap-2 items-center tabular-nums">
|
||||||
@@ -162,6 +177,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "protocol",
|
id: "protocol",
|
||||||
|
meta: { label: t`Protocol` },
|
||||||
accessorFn: (record) => record.protocol,
|
accessorFn: (record) => record.protocol,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
@@ -171,6 +187,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "interval",
|
id: "interval",
|
||||||
|
meta: { label: t`Interval` },
|
||||||
accessorFn: (record) => record.interval,
|
accessorFn: (record) => record.interval,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={RefreshCwIcon} />,
|
||||||
@@ -178,6 +195,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "res",
|
id: "res",
|
||||||
|
meta: { label: t`Response` },
|
||||||
accessorFn: (record) => record.res,
|
accessorFn: (record) => record.res,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Response`} Icon={TimerIcon} />,
|
||||||
@@ -185,6 +203,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "res1h",
|
id: "res1h",
|
||||||
|
meta: { label: t`Avg 1h` },
|
||||||
accessorFn: (record) => record.resAvg1h,
|
accessorFn: (record) => record.resAvg1h,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Avg 1h`} Icon={TimerIcon} />,
|
||||||
@@ -192,6 +211,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "max1h",
|
id: "max1h",
|
||||||
|
meta: { label: t`Max 1h` },
|
||||||
accessorFn: (record) => record.resMax1h,
|
accessorFn: (record) => record.resMax1h,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Max 1h`} Icon={TimerIcon} />,
|
||||||
@@ -199,6 +219,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "min1h",
|
id: "min1h",
|
||||||
|
meta: { label: t`Min 1h` },
|
||||||
accessorFn: (record) => record.resMin1h,
|
accessorFn: (record) => record.resMin1h,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Min 1h`} Icon={TimerIcon} />,
|
||||||
@@ -206,6 +227,7 @@ export function getMonitorColumns(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "loss",
|
id: "loss",
|
||||||
|
meta: { label: t`Loss 1h` },
|
||||||
accessorFn: (record) => record.loss1h,
|
accessorFn: (record) => record.loss1h,
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Loss 1h`} Icon={WifiOffIcon} />,
|
||||||
@@ -232,8 +254,34 @@ export function getMonitorColumns(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "cert",
|
||||||
|
meta: { label: t`Certificate` },
|
||||||
|
accessorFn: (record) => record.certInfo?.expires,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Certificate`} Icon={ShieldCheckIcon} />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { certInfo, system } = row.original
|
||||||
|
const systemRecord = useStore($allSystemsById)[system]
|
||||||
|
|
||||||
|
if (!certInfo?.expires) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysLeft = getCertDaysLeft(certInfo)
|
||||||
|
const color = isMuted(row.original, systemRecord)
|
||||||
|
? "bg-muted-foreground/50"
|
||||||
|
: certExpiryDotColors[getCertExpiryLevel(daysLeft)]
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||||
|
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||||
|
{daysLeft < 0 ? <Trans>Expired</Trans> : <Plural value={daysLeft} one="# day" other="# days" />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "updated",
|
id: "updated",
|
||||||
|
meta: { label: t`Updated` },
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
accessorFn: (record) => record.updated,
|
accessorFn: (record) => record.updated,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getMonitorTarget } from "@/lib/network-monitor-utils"
|
import { getCertDaysLeft, getCertExpiryLevel, getMonitorTarget } from "@/lib/network-monitor-utils"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import {
|
import {
|
||||||
@@ -26,26 +26,44 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
import { memo, useCallback, useMemo, useRef, useState } from "react"
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { subscribeKeys } from "nanostores"
|
||||||
import { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-columns"
|
import { getMonitorColumns } from "@/components/network-monitors-table/network-monitors-columns"
|
||||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { isReadOnlyUser } from "@/lib/api"
|
import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { $allSystemsById, $direction, $userSettings } from "@/lib/stores"
|
import { SystemStatus } from "@/lib/enums"
|
||||||
import {
|
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
|
||||||
cn,
|
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
|
||||||
isVisuallyLonger,
|
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
|
||||||
matchesFilterGroups,
|
|
||||||
parseFilterGroups,
|
|
||||||
parseSemVer,
|
|
||||||
useBrowserStorage,
|
|
||||||
} from "@/lib/utils"
|
|
||||||
import type { ChartData, NetworkMonitorRecord } from "@/types"
|
|
||||||
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
|
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
|
||||||
import { ArrowLeftRightIcon, EthernetPortIcon, LoaderCircleIcon, ServerIcon, XIcon } from "lucide-react"
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
EthernetPortIcon,
|
||||||
|
EyeIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LandmarkIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts"
|
import { LossChart, AvgMinMaxResponseChart } from "@/components/routes/system/charts/monitors-charts"
|
||||||
@@ -65,13 +83,19 @@ export default function NetworkMonitorsTableNew({
|
|||||||
monitors: NetworkMonitorRecord[]
|
monitors: NetworkMonitorRecord[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const sortSettingsKey = systemId ? "monitorSortModeSystem" : "monitorSortMode"
|
||||||
`sort-np-target-${systemId ? 1 : 0}`,
|
const sortStorageKey = `besz-sort-np-target-${systemId ? 1 : 0}`
|
||||||
[{ id: systemId ? "target" : "system", desc: false }],
|
const [sorting, setSorting] = useState<SortingState>(
|
||||||
sessionStorage
|
() =>
|
||||||
|
$userSettings.get()[sortSettingsKey] ??
|
||||||
|
JSON.parse(sessionStorage.getItem(sortStorageKey) || "null") ?? [
|
||||||
|
{ id: systemId ? "target" : "system", desc: false },
|
||||||
|
]
|
||||||
)
|
)
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||||
|
() => $userSettings.get().monitorCols ?? JSON.parse(localStorage.getItem("besz-monitor-cols") || "{}")
|
||||||
|
)
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
@@ -81,6 +105,49 @@ export default function NetworkMonitorsTableNew({
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const canManageMonitors = !isReadOnlyUser()
|
const canManageMonitors = !isReadOnlyUser()
|
||||||
|
|
||||||
|
// Apply settings from server once they load (handles incognito / new devices)
|
||||||
|
const appliedSettings = useRef(new Set<string>())
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeKeys($userSettings, ["monitorCols", sortSettingsKey], (vals) => {
|
||||||
|
if (!appliedSettings.current.has("monitorCols") && vals.monitorCols !== undefined) {
|
||||||
|
appliedSettings.current.add("monitorCols")
|
||||||
|
setColumnVisibility(vals.monitorCols)
|
||||||
|
}
|
||||||
|
if (!appliedSettings.current.has(sortSettingsKey) && vals[sortSettingsKey] !== undefined) {
|
||||||
|
appliedSettings.current.add(sortSettingsKey)
|
||||||
|
setSorting(vals[sortSettingsKey] as SortingState)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [sortSettingsKey])
|
||||||
|
|
||||||
|
const handleColumnVisibilityChange = useCallback(
|
||||||
|
(updater: VisibilityState | ((prev: VisibilityState) => VisibilityState)) => {
|
||||||
|
setColumnVisibility((prev) => {
|
||||||
|
const next = typeof updater === "function" ? updater(prev) : updater
|
||||||
|
localStorage.setItem("besz-monitor-cols", JSON.stringify(next))
|
||||||
|
$userSettings.setKey("monitorCols", next)
|
||||||
|
queueUserSettings({ monitorCols: next })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSortingChange = useCallback(
|
||||||
|
(updater: SortingState | ((prev: SortingState) => SortingState)) => {
|
||||||
|
setSorting((prev) => {
|
||||||
|
const next = typeof updater === "function" ? updater(prev) : updater
|
||||||
|
sessionStorage.setItem(sortStorageKey, JSON.stringify(next))
|
||||||
|
$userSettings.setKey(sortSettingsKey, next)
|
||||||
|
queueUserSettings({ [sortSettingsKey]: next })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[sortSettingsKey, sortStorageKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
// recompute when measured widths are invalidated (e.g. web font finished loading)
|
||||||
|
const textMeasureVersion = useStore($textMeasureVersion)
|
||||||
const longestTarget = useMemo(() => {
|
const longestTarget = useMemo(() => {
|
||||||
let longestTarget = ""
|
let longestTarget = ""
|
||||||
for (const p of monitors) {
|
for (const p of monitors) {
|
||||||
@@ -89,7 +156,27 @@ export default function NetworkMonitorsTableNew({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return longestTarget
|
return longestTarget
|
||||||
}, [monitors])
|
}, [monitors, textMeasureVersion])
|
||||||
|
|
||||||
|
// longest name among systems that have monitors in this table (skipped for single-system view).
|
||||||
|
// Held in a store because memoized rows don't re-render when column definitions change.
|
||||||
|
const $longestSystemName = useMemo(() => atom(""), [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (systemId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const systemIds = new Set(monitors.map((m) => m.system))
|
||||||
|
return $allSystemsById.subscribe((systems) => {
|
||||||
|
let longest = ""
|
||||||
|
for (const id of systemIds) {
|
||||||
|
const name = systems[id]?.name ?? ""
|
||||||
|
if (isVisuallyLonger(name, longest)) {
|
||||||
|
longest = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$longestSystemName.set(longest)
|
||||||
|
})
|
||||||
|
}, [monitors, systemId, textMeasureVersion, $longestSystemName])
|
||||||
|
|
||||||
const runMonitorBatch = useCallback(
|
const runMonitorBatch = useCallback(
|
||||||
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
|
||||||
@@ -190,7 +277,7 @@ export default function NetworkMonitorsTableNew({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
let columns = getMonitorColumns(longestTarget, {
|
let columns = getMonitorColumns(longestTarget, $longestSystemName, {
|
||||||
onEdit: setEditingMonitor,
|
onEdit: setEditingMonitor,
|
||||||
onDelete: handleDeleteRequest,
|
onDelete: handleDeleteRequest,
|
||||||
onSetEnabled: handleSetEnabled,
|
onSetEnabled: handleSetEnabled,
|
||||||
@@ -198,7 +285,7 @@ export default function NetworkMonitorsTableNew({
|
|||||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
columns = canManageMonitors ? columns : columns.filter((col) => col.id !== "actions")
|
columns = canManageMonitors ? columns : columns.filter((col) => col.id !== "actions")
|
||||||
return columns
|
return columns
|
||||||
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget])
|
}, [canManageMonitors, handleDeleteRequest, handleSetEnabled, systemId, longestTarget, $longestSystemName])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: monitors,
|
data: monitors,
|
||||||
@@ -207,9 +294,9 @@ export default function NetworkMonitorsTableNew({
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
onSortingChange: setSorting,
|
onSortingChange: handleSortingChange,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: handleColumnVisibilityChange,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
sortUndefined: "last",
|
sortUndefined: "last",
|
||||||
@@ -236,11 +323,12 @@ export default function NetworkMonitorsTableNew({
|
|||||||
|
|
||||||
const rows = table.getRowModel().rows
|
const rows = table.getRowModel().rows
|
||||||
const visibleColumns = table.getVisibleLeafColumns()
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
|
const visibleColumnsKey = visibleColumns.map((column) => column.id).join(",")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
<div className="grid md-lg:flex gap-x-5 gap-y-3 w-full items-end">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">
|
||||||
<Trans>Network Monitors</Trans>
|
<Trans>Network Monitors</Trans>
|
||||||
@@ -249,15 +337,14 @@ export default function NetworkMonitorsTableNew({
|
|||||||
<Trans>Response time monitoring from agents.</Trans>
|
<Trans>Response time monitoring from agents.</Trans>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:ms-auto flex items-center gap-2">
|
<div className="md-lg:ms-auto flex items-center gap-2">
|
||||||
{monitors.length > 0 && (
|
{monitors.length > 0 && (
|
||||||
<div className="relative">
|
<div className="relative grow">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
|
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-50"
|
className="ms-auto px-4 w-full max-w-full md-lg:w-50"
|
||||||
/>
|
/>
|
||||||
{globalFilter && (
|
{globalFilter && (
|
||||||
<Button
|
<Button
|
||||||
@@ -273,6 +360,74 @@ export default function NetworkMonitorsTableNew({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Settings2Icon className="me-1.5 size-4 opacity-80" />
|
||||||
|
<Trans>View</Trans>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
|
||||||
|
<div className="grid grid-cols-2 divide-y md:divide-s md:divide-y-0">
|
||||||
|
<div className="border-r">
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
<Trans>Sort By</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1 pb-1">
|
||||||
|
{table.getAllColumns().map((column) => {
|
||||||
|
if (!column.getCanSort()) return null
|
||||||
|
let Icon = <span className="w-6"></span>
|
||||||
|
if (sorting[0]?.id === column.id) {
|
||||||
|
Icon = sorting[0]?.desc ? (
|
||||||
|
<ArrowUpIcon className="me-2 size-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownIcon className="me-2 size-4" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSortingChange([
|
||||||
|
{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc },
|
||||||
|
])
|
||||||
|
}}
|
||||||
|
key={column.id}
|
||||||
|
>
|
||||||
|
{Icon}
|
||||||
|
{column.columnDef.meta?.label ?? column.id}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
|
||||||
|
<EyeIcon className="size-4" />
|
||||||
|
<Trans>Visible Fields</Trans>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-1.5 pb-1">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.columnDef.meta?.label ?? column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
|
{canManageMonitors ? <AddMonitorDialog systemId={systemId} monitors={monitors} /> : null}
|
||||||
{canManageMonitors ? (
|
{canManageMonitors ? (
|
||||||
<EditMonitorDialog
|
<EditMonitorDialog
|
||||||
@@ -325,6 +480,7 @@ export default function NetworkMonitorsTableNew({
|
|||||||
table={table}
|
table={table}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
colLength={visibleColumns.length}
|
colLength={visibleColumns.length}
|
||||||
|
visibleColumnsKey={visibleColumnsKey}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
@@ -337,12 +493,14 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
|
|||||||
table,
|
table,
|
||||||
rows,
|
rows,
|
||||||
colLength,
|
colLength,
|
||||||
|
visibleColumnsKey,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
isLoading,
|
isLoading,
|
||||||
}: {
|
}: {
|
||||||
table: TableType<NetworkMonitorRecord>
|
table: TableType<NetworkMonitorRecord>
|
||||||
rows: Row<NetworkMonitorRecord>[]
|
rows: Row<NetworkMonitorRecord>[]
|
||||||
colLength: number
|
colLength: number
|
||||||
|
visibleColumnsKey: string
|
||||||
rowSelection: RowSelectionState
|
rowSelection: RowSelectionState
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -390,6 +548,7 @@ const NetworkMonitorsTable = memo(function NetworkMonitorTable({
|
|||||||
virtualRow={virtualRow}
|
virtualRow={virtualRow}
|
||||||
isSelected={row.getIsSelected()}
|
isSelected={row.getIsSelected()}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
|
visibleColumnsKey={visibleColumnsKey}
|
||||||
openSheet={openSheet}
|
openSheet={openSheet}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -442,6 +601,9 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
|
|||||||
virtualRow,
|
virtualRow,
|
||||||
isSelected,
|
isSelected,
|
||||||
rowSelection: _rowSelection,
|
rowSelection: _rowSelection,
|
||||||
|
// Column visibility doesn't change the row object identity, so this prop exists only
|
||||||
|
// to force a re-render (and a fresh row.getVisibleCells() read) when columns are toggled.
|
||||||
|
visibleColumnsKey: _visibleColumnsKey,
|
||||||
openSheet,
|
openSheet,
|
||||||
}: {
|
}: {
|
||||||
row: Row<NetworkMonitorRecord>
|
row: Row<NetworkMonitorRecord>
|
||||||
@@ -449,12 +611,16 @@ const NetworkMonitorTableRow = memo(function NetworkMonitorTableRow({
|
|||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
// Menus depend on the entire selection, including changes to other rows.
|
// Menus depend on the entire selection, including changes to other rows.
|
||||||
rowSelection: RowSelectionState
|
rowSelection: RowSelectionState
|
||||||
|
visibleColumnsKey: string
|
||||||
openSheet: (monitor: NetworkMonitorRecord) => void
|
openSheet: (monitor: NetworkMonitorRecord) => void
|
||||||
}) {
|
}) {
|
||||||
|
const system = useStore($allSystemsById)[row.original.system]
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
data-state={isSelected && "selected"}
|
data-state={isSelected && "selected"}
|
||||||
className="cursor-pointer transition-opacity"
|
className={cn("cursor-pointer transition-opacity", {
|
||||||
|
"opacity-50": system?.status === SystemStatus.Paused,
|
||||||
|
})}
|
||||||
onClick={() => openSheet(row.original)}
|
onClick={() => openSheet(row.original)}
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
@@ -489,6 +655,36 @@ function NetworkMonitorSheet({
|
|||||||
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
|
return <NetworkMonitorSheetContent key={monitor.system} open={open} onOpenChange={onOpenChange} monitor={monitor} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const certExpiryTextColors = { ok: "", warning: "text-yellow-600 dark:text-yellow-500", critical: "text-red-500" }
|
||||||
|
|
||||||
|
function CertExpiry({ cert }: { cert: MonitorCertInfo }) {
|
||||||
|
const daysLeft = getCertDaysLeft(cert)
|
||||||
|
const expires = formatShortDate(new Date(cert.expires).toISOString())
|
||||||
|
const level = getCertExpiryLevel(daysLeft)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<ShieldCheckIcon className={cn("size-3.5 text-muted-foreground -me-1", certExpiryTextColors[level])} />
|
||||||
|
<span className={certExpiryTextColors[level]}>
|
||||||
|
{daysLeft < 0 ? (
|
||||||
|
<Trans>Certificate expired {expires}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Certificate expires {expires}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{cert.issuer && (
|
||||||
|
<>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<LandmarkIcon className="size-3.5 text-muted-foreground -me-0.5" />
|
||||||
|
<span>{cert.issuer}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NetworkMonitorSheetContent({
|
function NetworkMonitorSheetContent({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -500,7 +696,7 @@ function NetworkMonitorSheetContent({
|
|||||||
}) {
|
}) {
|
||||||
// Keep monitor exploration independent of the system charts' time range.
|
// Keep monitor exploration independent of the system charts' time range.
|
||||||
const [chartTimeStore] = useState(() => {
|
const [chartTimeStore] = useState(() => {
|
||||||
const defaultTime = $userSettings.get().chartTime
|
const defaultTime = getUserChartTime()
|
||||||
return atom(defaultTime === "1m" ? "1h" : defaultTime)
|
return atom(defaultTime === "1m" ? "1h" : defaultTime)
|
||||||
})
|
})
|
||||||
const chartTime = useStore(chartTimeStore)
|
const chartTime = useStore(chartTimeStore)
|
||||||
@@ -536,7 +732,7 @@ function NetworkMonitorSheetContent({
|
|||||||
{system?.name ?? ""}
|
{system?.name ?? ""}
|
||||||
</Link>
|
</Link>
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground" />
|
<ArrowLeftRightIcon className="size-3.5 text-muted-foreground -me-0.5" />
|
||||||
{monitor.protocol.toUpperCase()}
|
{monitor.protocol.toUpperCase()}
|
||||||
{monitor.protocol === "tcp" && monitor.port > 0 && (
|
{monitor.protocol === "tcp" && monitor.port > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -545,6 +741,14 @@ function NetworkMonitorSheetContent({
|
|||||||
<span>{monitor.port}</span>
|
<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>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Slider from "@/components/ui/slider"
|
|||||||
import { HourFormat, Unit } from "@/lib/enums"
|
import { HourFormat, Unit } from "@/lib/enums"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { $userSettings, defaultLayoutWidth } from "@/lib/stores"
|
import { $chartTime, $userSettings, defaultLayoutWidth, getUserChartTime } from "@/lib/stores"
|
||||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
@@ -22,6 +22,9 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
const currentUserSettings = useStore($userSettings)
|
const currentUserSettings = useStore($userSettings)
|
||||||
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
|
const layoutWidth = currentUserSettings.layoutWidth ?? defaultLayoutWidth
|
||||||
|
// without a value the hidden select submits an empty string, which would persist
|
||||||
|
// a chart time that no longer loads any data (#2104)
|
||||||
|
const chartTime = getUserChartTime(userSettings)
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -29,6 +32,8 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
const formData = new FormData(e.target as HTMLFormElement)
|
const formData = new FormData(e.target as HTMLFormElement)
|
||||||
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
const data = Object.fromEntries(formData) as Partial<UserSettings>
|
||||||
await saveSettings(data)
|
await saveSettings(data)
|
||||||
|
// apply the saved default time period to the active charts
|
||||||
|
$chartTime.set(getUserChartTime())
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +127,7 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
<Label className="block" htmlFor="chartTime">
|
<Label className="block" htmlFor="chartTime">
|
||||||
<Trans>Default time period</Trans>
|
<Trans>Default time period</Trans>
|
||||||
</Label>
|
</Label>
|
||||||
<Select name="chartTime" key={userSettings.chartTime} defaultValue={userSettings.chartTime}>
|
<Select name="chartTime" key={chartTime} defaultValue={chartTime}>
|
||||||
<SelectTrigger id="chartTime">
|
<SelectTrigger id="chartTime">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
|||||||
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
import { ZfsCharts } from "./system/charts/storage-pool-charts"
|
||||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
|
import { WiFiChart } from "./system/charts/wifi-chart"
|
||||||
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
|
||||||
import {
|
import {
|
||||||
LazyContainersTable,
|
LazyContainersTable,
|
||||||
@@ -135,6 +136,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
|
|
||||||
<BatteryChart system={system} {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
|
<WiFiChart system={system} {...coreProps} />
|
||||||
|
|
||||||
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +213,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||||
<FanChart {...coreProps} />
|
<FanChart {...coreProps} />
|
||||||
<BatteryChart system={system} {...coreProps} />
|
<BatteryChart system={system} {...coreProps} />
|
||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -221,6 +222,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<>
|
<>
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||||
|
<WiFiChart system={system} {...coreProps} />
|
||||||
</div>
|
</div>
|
||||||
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
|
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ export function FilterBar({ store = $containerFilter }: { store?: typeof $contai
|
|||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
title={t`Use commas to match any of multiple terms, e.g. "system1, system2"`}
|
|
||||||
className="ps-4 pe-8 w-full sm:w-44"
|
className="ps-4 pe-8 w-full sm:w-44"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Fragment, type ReactNode, useRef, useMemo } from "react"
|
|||||||
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||||
import LineChartDefault from "@/components/charts/line-chart"
|
import LineChartDefault from "@/components/charts/line-chart"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
|
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
|
||||||
import { ChartCard } from "../chart-card"
|
import { ChartCard } from "../chart-card"
|
||||||
|
|
||||||
@@ -79,7 +79,6 @@ export function GpuPowerChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
className={cn(grid && "!col-span-1")}
|
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`GPU Power Draw`}
|
title={t`GPU Power Draw`}
|
||||||
@@ -97,8 +96,8 @@ export function GpuPowerChart({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All GPU charts (optional power-draw slot + engines + per-GPU usage/VRAM) in a single 2-col grid, so the
|
/** GPU charts: summary grid (optional power-draw slot + engines) above a per-GPU usage/VRAM grid. Separate
|
||||||
* cards' odd:last-of-type parity rule flows across the whole tab and no row is left half-empty */
|
* grids keep each GPU's usage and VRAM cards paired, while odd:last-of-type stretches a lone summary card */
|
||||||
export function GpuCharts({
|
export function GpuCharts({
|
||||||
chartData,
|
chartData,
|
||||||
grid,
|
grid,
|
||||||
@@ -114,77 +113,86 @@ export function GpuCharts({
|
|||||||
hasGpuEnginesData: boolean
|
hasGpuEnginesData: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const gpuIds = Object.keys(lastGpus)
|
||||||
return (
|
return (
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid gap-4">
|
||||||
{children}
|
{(children || hasGpuEnginesData) && (
|
||||||
{hasGpuEnginesData && (
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
{children}
|
||||||
legend={true}
|
{hasGpuEnginesData && (
|
||||||
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
|
<ChartCard
|
||||||
|
legend={true}
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${gpu.n} ${t`Usage`}`}
|
title={t`GPU Engines`}
|
||||||
description={t`Average utilization of ${gpu.n}`}
|
description={t`Average utilization of GPU engines`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<GpuEnginesChart chartData={chartData} />
|
||||||
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>
|
</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 && (
|
{(gpu.mt ?? 0) > 0 && (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${gpu.n} VRAM`}
|
title={`${gpu.n} VRAM`}
|
||||||
description={t`Precise utilization at the recorded time`}
|
description={t`Precise utilization at the recorded time`}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t`Usage`,
|
label: t`Usage`,
|
||||||
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
|
||||||
color: 2,
|
color: 2,
|
||||||
opacity: 0.25,
|
opacity: 0.25,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
max={gpu.mt}
|
max={gpu.mt}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
}}
|
}}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
|
||||||
return `${decimalString(convertedValue)} ${unit}`
|
return `${decimalString(convertedValue)} ${unit}`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type MonitorChartBaseProps = MonitorChartProps & {
|
|||||||
tickFormatter: (value: number) => string
|
tickFormatter: (value: number) => string
|
||||||
contentFormatter: ({ value }: { value: number | string }) => string | number
|
contentFormatter: ({ value }: { value: number | string }) => string | number
|
||||||
domain?: [number | "auto", number | "auto"]
|
domain?: [number | "auto", number | "auto"]
|
||||||
|
/** Overrides the per-monitor line colors (e.g. a fixed color for single-monitor charts). */
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function MonitorChart({
|
function MonitorChart({
|
||||||
@@ -41,6 +43,7 @@ function MonitorChart({
|
|||||||
tickFormatter,
|
tickFormatter,
|
||||||
contentFormatter,
|
contentFormatter,
|
||||||
domain,
|
domain,
|
||||||
|
color,
|
||||||
showFilter = monitors.length > 1,
|
showFilter = monitors.length > 1,
|
||||||
}: MonitorChartBaseProps) {
|
}: MonitorChartBaseProps) {
|
||||||
const storedFilter = useStore($monitorFilter)
|
const storedFilter = useStore($monitorFilter)
|
||||||
@@ -67,11 +70,12 @@ function MonitorChart({
|
|||||||
label,
|
label,
|
||||||
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
|
dataKey: (record: NetworkMonitorStatsRecord) => record.stats?.[p.id]?.[metric] ?? null,
|
||||||
dot,
|
dot,
|
||||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
color:
|
||||||
|
color ?? (count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { dataPoints: points, visibleKeys: visibleIDs }
|
return { dataPoints: points, visibleKeys: visibleIDs }
|
||||||
}, [monitors, filter, metric, chartData.chartTime])
|
}, [monitors, filter, metric, chartData.chartTime, color])
|
||||||
|
|
||||||
const filteredMonitorStats = useMemo(() => {
|
const filteredMonitorStats = useMemo(() => {
|
||||||
if (!visibleKeys.length) return monitorStats
|
if (!visibleKeys.length) return monitorStats
|
||||||
@@ -186,7 +190,7 @@ export function AvgMinMaxResponseChart({ monitorStats, monitor, chartData, empty
|
|||||||
|
|
||||||
export function LossChart({ monitorStats, grid, monitors, chartData, empty, titlePrefix }: MonitorChartProps) {
|
export function LossChart({ monitorStats, grid, monitors, chartData, empty, titlePrefix }: MonitorChartProps) {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const lossTitle = t`Loss`
|
const lossTitle = t({ message: "Loss", context: "Packet loss" })
|
||||||
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
|
const title = titlePrefix ? `${titlePrefix} — ${lossTitle}` : lossTitle
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -200,6 +204,7 @@ export function LossChart({ monitorStats, grid, monitors, chartData, empty, titl
|
|||||||
title={title}
|
title={title}
|
||||||
description={t`Packet loss (%)`}
|
description={t`Packet loss (%)`}
|
||||||
domain={[0, 100]}
|
domain={[0, 100]}
|
||||||
|
color="var(--destructive)"
|
||||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
|
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)}%`}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
if (typeof value !== "number") {
|
if (typeof value !== "number") {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import LineChartDefault from "@/components/charts/line-chart"
|
||||||
|
import { connectedWiFi, wifiColor } from "@/lib/wifi"
|
||||||
|
import type { ChartData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
|
import { ChartCard } from "../chart-card"
|
||||||
|
|
||||||
|
export function WiFiChart({
|
||||||
|
system,
|
||||||
|
chartData,
|
||||||
|
grid,
|
||||||
|
dataEmpty,
|
||||||
|
}: {
|
||||||
|
system: SystemRecord
|
||||||
|
chartData: ChartData
|
||||||
|
grid: boolean
|
||||||
|
dataEmpty: boolean
|
||||||
|
}) {
|
||||||
|
const interfaces = connectedWiFi(system)
|
||||||
|
// Associated interfaces may not report RSSI; without any readings the chart would never render.
|
||||||
|
const hasSignal = interfaces.some(
|
||||||
|
([id, wifi]) => wifi.r !== undefined || chartData.systemStats.some((record) => record.stats?.wf?.[id] !== undefined)
|
||||||
|
)
|
||||||
|
if (!hasSignal) return null
|
||||||
|
const dataPoints = interfaces.map(([id, current]) => ({
|
||||||
|
label: current.s ? `${id} (${current.s})` : id,
|
||||||
|
color: wifiColor(id),
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.wf?.[id],
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Wi-Fi signal`}
|
||||||
|
description={t`Signal strength of connected Wi-Fi interfaces`}
|
||||||
|
>
|
||||||
|
<LineChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
dataPoints={dataPoints}
|
||||||
|
domain={["auto", "auto"]}
|
||||||
|
legend={true}
|
||||||
|
tickFormatter={(value) => `${value} dBm`}
|
||||||
|
contentFormatter={({ value }) => `${value} dBm`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -161,7 +161,7 @@ export default function InfoBar({
|
|||||||
{translatedStatus}
|
{translatedStatus}
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{system.info.ct && (
|
{!!system.info.ct && (
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex gap-1 items-center">
|
||||||
{system.info.ct === ConnectionType.WebSocket ? (
|
{system.info.ct === ConnectionType.WebSocket ? (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user