mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +01:00
Compare commits
4 Commits
53a7e06dcf
...
a6e84c207e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e84c207e | ||
|
|
249402eaed | ||
|
|
394c476f2a | ||
|
|
86e8a141ea |
@@ -16,10 +16,21 @@ builds:
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: arm64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
|
||||
- id: beszel-agent
|
||||
binary: beszel-agent
|
||||
@@ -86,6 +97,9 @@ archives:
|
||||
{{ .Binary }}_
|
||||
{{- .Os }}_
|
||||
{{- .Arch }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
nfpms:
|
||||
- id: beszel-agent
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
"github.com/henrygd/beszel"
|
||||
@@ -29,6 +30,8 @@ type Agent struct {
|
||||
fsNames []string // List of filesystem device names being monitored
|
||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||
@@ -69,6 +72,16 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
|
||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||
agent.sensorConfig = agent.newSensorConfig()
|
||||
|
||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||
agent.diskUsageCacheDuration = duration
|
||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||
} else {
|
||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||
}
|
||||
}
|
||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||
switch strings.ToLower(logLevelStr) {
|
||||
|
||||
@@ -225,8 +225,19 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
||||
|
||||
// Updates disk usage statistics for all monitored filesystems
|
||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
|
||||
// Root filesystem is always updated since it can't be sleeping while the agent runs.
|
||||
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
|
||||
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
|
||||
!a.lastDiskUsageUpdate.IsZero() &&
|
||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
||||
|
||||
// disk usage
|
||||
for _, stats := range a.fsStats {
|
||||
// Skip non-root filesystems if caching is active
|
||||
if cacheExtraFs && !stats.Root {
|
||||
continue
|
||||
}
|
||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||
@@ -244,6 +255,11 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||
stats.TotalWrite = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last disk usage update time when we've collected extra filesystems
|
||||
if !cacheExtraFs {
|
||||
a.lastDiskUsageUpdate = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Updates disk I/O statistics for all monitored filesystems
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/shirou/gopsutil/v4/disk"
|
||||
@@ -233,3 +234,86 @@ func TestExtraFsKeyGeneration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskUsageCaching(t *testing.T) {
|
||||
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 0, // caching disabled
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// Both should be updated (non-zero values from disk.Usage)
|
||||
// Root stats should be populated in systemStats
|
||||
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
||||
"lastDiskUsageUpdate should be set when caching is disabled")
|
||||
})
|
||||
|
||||
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Hour,
|
||||
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
||||
}
|
||||
|
||||
// Store original extra fs values
|
||||
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
||||
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// Root should be updated (systemStats populated from disk.Usage call)
|
||||
// We can't easily check if disk.Usage was called, but we verify the flow works
|
||||
|
||||
// Extra filesystem should retain cached values (not reset)
|
||||
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
||||
"extra filesystem DiskTotal should be unchanged when cached")
|
||||
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
||||
"extra filesystem DiskUsed should be unchanged when cached")
|
||||
})
|
||||
|
||||
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Hour,
|
||||
// lastDiskUsageUpdate is zero (first call)
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// After first call, lastDiskUsageUpdate should be set
|
||||
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
||||
"lastDiskUsageUpdate should be set after first call")
|
||||
})
|
||||
|
||||
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
||||
agent := &Agent{
|
||||
fsStats: map[string]*system.FsStats{
|
||||
"sda": {Root: true, Mountpoint: "/"},
|
||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||
},
|
||||
diskUsageCacheDuration: 1 * time.Millisecond,
|
||||
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
||||
}
|
||||
|
||||
var stats system.Stats
|
||||
agent.updateDiskUsage(&stats)
|
||||
|
||||
// lastDiskUsageUpdate should be refreshed since cache expired
|
||||
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
||||
"lastDiskUsageUpdate should be refreshed when cache expires")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -24,6 +25,10 @@ import (
|
||||
"github.com/blang/semver"
|
||||
)
|
||||
|
||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
|
||||
const (
|
||||
// Docker API timeout in milliseconds
|
||||
dockerTimeoutMs = 2100
|
||||
@@ -692,7 +697,12 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
||||
return "", err
|
||||
}
|
||||
|
||||
return builder.String(), nil
|
||||
// Strip ANSI escape sequences from logs for clean display in web UI
|
||||
logs := builder.String()
|
||||
if strings.Contains(logs, "\x1b") {
|
||||
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
||||
|
||||
@@ -1203,3 +1203,60 @@ func TestShouldExcludeContainer(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiEscapePattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no ANSI codes",
|
||||
input: "Hello, World!",
|
||||
expected: "Hello, World!",
|
||||
},
|
||||
{
|
||||
name: "simple color code",
|
||||
input: "\x1b[34mINFO\x1b[0m client mode",
|
||||
expected: "INFO client mode",
|
||||
},
|
||||
{
|
||||
name: "multiple color codes",
|
||||
input: "\x1b[31mERROR\x1b[0m: \x1b[33mWarning\x1b[0m message",
|
||||
expected: "ERROR: Warning message",
|
||||
},
|
||||
{
|
||||
name: "bold and color",
|
||||
input: "\x1b[1;32mSUCCESS\x1b[0m",
|
||||
expected: "SUCCESS",
|
||||
},
|
||||
{
|
||||
name: "cursor movement codes",
|
||||
input: "Line 1\x1b[KLine 2",
|
||||
expected: "Line 1Line 2",
|
||||
},
|
||||
{
|
||||
name: "256 color code",
|
||||
input: "\x1b[38;5;196mRed text\x1b[0m",
|
||||
expected: "Red text",
|
||||
},
|
||||
{
|
||||
name: "RGB/truecolor code",
|
||||
input: "\x1b[38;2;255;0;0mRed text\x1b[0m",
|
||||
expected: "Red text",
|
||||
},
|
||||
{
|
||||
name: "mixed content with newlines",
|
||||
input: "\x1b[34m2024-01-01 12:00:00\x1b[0m INFO Starting\n\x1b[31m2024-01-01 12:00:01\x1b[0m ERROR Failed",
|
||||
expected: "2024-01-01 12:00:00 INFO Starting\n2024-01-01 12:00:01 ERROR Failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ansiEscapePattern.ReplaceAllString(tt.input, "")
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
## 0.17.0
|
||||
|
||||
- Add quiet hours to silence alerts during specific time periods. (#265)
|
||||
|
||||
- Add dedicated S.M.A.R.T. page with persistent device storage.
|
||||
|
||||
- Add alerts for S.M.A.R.T. failures.
|
||||
|
||||
- Add `DISK_USAGE_CACHE` environment variable. (#1426)
|
||||
|
||||
- Add `SKIP_SYSTEMD` environment variable. (#1448)
|
||||
|
||||
- Add hub builds for Windows and FreeBSD.
|
||||
|
||||
- Change extra disk indicators in systems table to display usage range as dots. (#1409)
|
||||
|
||||
- Add clear button to filter inputs in all systems and containers tables. (#1447)
|
||||
|
||||
- Strip ANSI escape sequences from docker logs. (#1478)
|
||||
|
||||
- Fix issue where the Add System button is visible to read-only users. (#1442)
|
||||
|
||||
- Fix font ligatures creating unwanted artifacts in random ids. (#1434)
|
||||
|
||||
- Update Go dependencies.
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Add services column to All Systems table. (#1153)
|
||||
|
||||
Reference in New Issue
Block a user