Merge remote-tracking branch 'upstream/main' into fix/package-updates-sandbox

This commit is contained in:
sveng93
2026-10-12 20:37:03 +02:00
60 changed files with 3182 additions and 1305 deletions
+51
View File
@@ -23,6 +23,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
"golang.org/x/net/http/httpguts"
"golang.org/x/net/proxy"
)
@@ -37,6 +38,9 @@ const (
// where the hub dials the agent and no outbound WebSocket client is expected.
var errNoHubURL = errors.New("HUB_URL environment variable not set")
// errInvalidHubHeaders returned when there is malformed HUB_HEADERS value.
var errInvalidHubHeaders = errors.New("invalid HUB_HEADERS")
type caCertFileError struct {
err error
}
@@ -64,6 +68,7 @@ type WebSocketClient struct {
lastConnectAttempt time.Time // Timestamp of last connection attempt
hubVerified bool // Whether the hub has been cryptographically verified
tlsConfig *tls.Config // Optional TLS configuration with custom CA certificates
hubHeaders http.Header // Extra request headers from HUB_HEADERS
}
// newWebSocketClient creates a new WebSocket client for the given agent.
@@ -89,6 +94,10 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
if err != nil {
return nil, err
}
client.hubHeaders, err = getHubHeaders()
if err != nil {
return nil, err
}
client.agent = agent
client.hubRequest = &common.HubRequest[cbor.RawMessage]{}
@@ -163,6 +172,38 @@ func getTLSConfig() (*tls.Config, error) {
return &tls.Config{RootCAs: rootCAs}, nil
}
// getHubHeaders parses HUB_HEADERS into extra headers sent with
// the WebSocket handshake.
//
// The value is a comma separated list of "Name: value" pairs:
//
// HUB_HEADERS="CF-Access-Client-Id: abc.access, CF-Access-Client-Secret: def456"
func getHubHeaders() (http.Header, error) {
raw, _ := utils.GetEnv("HUB_HEADERS")
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var headers http.Header
for i, entry := range strings.FieldsFunc(raw, func(r rune) bool {
return r == ',' || r == '\n' || r == '\r'
}) {
// values are often secrets, so errors must not include them
name, value, ok := strings.Cut(entry, ":")
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if !ok || !httpguts.ValidHeaderFieldName(name) {
return nil, fmt.Errorf("%w: entry %d is not a valid 'Name: value' pair", errInvalidHubHeaders, i+1)
}
if !httpguts.ValidHeaderFieldValue(value) {
return nil, fmt.Errorf("%w: invalid value for header %q", errInvalidHubHeaders, name)
}
if headers == nil {
headers = make(http.Header)
}
headers.Add(name, value)
}
return headers, nil
}
// getOptions returns the WebSocket client options, creating them if necessary.
// It configures the connection URL, TLS settings, and authentication headers.
func (client *WebSocketClient) getOptions() *gws.ClientOption {
@@ -195,6 +236,16 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
return proxy.FromEnvironment(), nil
},
}
// hub rejects a handshake without a valid X-Token or X-Beszel, ignoring user supplied values
for name, values := range client.hubHeaders {
if name == "X-Token" || name == "X-Beszel" {
slog.Warn("Ignoring reserved header in HUB_HEADERS", "header", name)
continue
}
client.options.RequestHeader[name] = values
}
return client.options
}
+124
View File
@@ -746,3 +746,127 @@ func TestWebSocketDeadlineCoversSlowCollection(t *testing.T) {
t.Fatalf("WebSocket deadline %s is shorter than the slow-collection window of %s", wsDeadline, minimumDeadline)
}
}
// TestGetHubHeaders covers HUB_HEADERS parsing
func TestGetHubHeaders(t *testing.T) {
testCases := []struct {
name string
raw string
want http.Header
wantErr bool
}{
{
name: "unset",
raw: "",
want: nil,
},
{
name: "two pairs with whitespace and trailing comma",
raw: " CF-Access-Client-Id:abc.access , CF-Access-Client-Secret: def456 ,",
want: http.Header{
"Cf-Access-Client-Id": []string{"abc.access"},
"Cf-Access-Client-Secret": []string{"def456"},
},
},
{
name: "written across lines",
raw: "X-One: 1,\nX-Two: 2\n",
want: http.Header{"X-One": []string{"1"}, "X-Two": []string{"2"}},
},
{
name: "value may contain colons and equals",
raw: "Authorization: Bearer a=b:c",
want: http.Header{"Authorization": []string{"Bearer a=b:c"}},
},
{
name: "empty value",
raw: "X-Empty:",
want: http.Header{"X-Empty": []string{""}},
},
{
name: "repeated name adds value",
raw: "X-Multi: one, X-Multi: two",
want: http.Header{"X-Multi": []string{"one", "two"}},
},
{
name: "second pair missing colon",
raw: "X-One: 1, oops",
wantErr: true,
},
{
name: "invalid header name",
raw: "Bad Header: value",
wantErr: true,
},
{
name: "invalid header value",
raw: "X-Bad: va\x7flue",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("BESZEL_AGENT_HUB_HEADERS", tc.raw)
headers, err := getHubHeaders()
if tc.wantErr {
require.Error(t, err)
assert.ErrorIs(t, err, errInvalidHubHeaders)
assert.Nil(t, headers)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, headers)
})
}
}
// TestGetHubHeadersErrorOmitsValues verifies that parse errors do not leak
// header values, which are often secrets
func TestGetHubHeadersErrorOmitsValues(t *testing.T) {
const secret = "super-secret-value"
for _, raw := range []string{
"CF-Access-Client-Secret: " + secret + "\x7f",
"Bad Name: " + secret,
secret,
} {
t.Setenv("BESZEL_AGENT_HUB_HEADERS", raw)
_, err := getHubHeaders()
require.ErrorIs(t, err, errInvalidHubHeaders)
assert.NotContains(t, err.Error(), secret)
}
}
// TestWebSocketClient_GetOptionsHubHeaders verifies that
// HUB_HEADERS values reach the handshake, and user supplied X-BESZEL values are ignored
func TestWebSocketClient_GetOptionsHubHeaders(t *testing.T) {
agent := createTestAgent(t)
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
t.Setenv("BESZEL_AGENT_HUB_HEADERS",
"CF-Access-Client-Id: abc.access, User-Agent: curl/8.0, x-token: hijack, X-BESZEL: 0.0.1")
client, err := newWebSocketClient(agent)
require.NoError(t, err)
header := client.getOptions().RequestHeader
assert.Equal(t, "abc.access", header.Get("Cf-Access-Client-Id"))
assert.Equal(t, "curl/8.0", header.Get("User-Agent"))
assert.Equal(t, "test-token", header.Get("X-Token"))
assert.Equal(t, beszel.Version, header.Get("X-Beszel"))
}
// TestNewWebSocketClientInvalidHubHeaders verifies that a malformed
// HUB_HEADERS value fails client creation
func TestNewWebSocketClientInvalidHubHeaders(t *testing.T) {
agent := createTestAgent(t)
t.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080")
t.Setenv("BESZEL_AGENT_TOKEN", "test-token")
t.Setenv("BESZEL_AGENT_HUB_HEADERS", "malfored-header")
client, err := newWebSocketClient(agent)
require.Error(t, err)
assert.Nil(t, client)
assert.ErrorIs(t, err, errInvalidHubHeaders)
}
+1 -1
View File
@@ -168,7 +168,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
wsClient, err := newWebSocketClient(c.agent)
if err != nil {
var caCertErr *caCertFileError
if errors.As(err, &caCertErr) {
if errors.As(err, &caCertErr) || errors.Is(err, errInvalidHubHeaders) {
return err
}
disableSSH, _ := utils.GetEnv("DISABLE_SSH")
+11 -4
View File
@@ -48,20 +48,28 @@ func getCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, error)
if err != nil || len(times) == 0 {
return CpuMetrics{}, err
}
return cpuMetricsFromTimes(cacheTimeMs, times[0]), nil
}
// cpuMetricsFromTimes calculates host CPU usage from the latest counters.
func cpuMetricsFromTimes(cacheTimeMs uint16, current cpu.TimesStat) CpuMetrics {
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
}
t1 := lastCpuTimes[cacheTimeMs]
t2 := times[0]
t2 := current
// A counter reset or wrap must establish a new baseline even if this
// sample cannot produce a valid delta.
lastCpuTimes[cacheTimeMs] = current
t1All, _ := getAllBusy(t1)
t2All, _ := getAllBusy(t2)
totalDelta := t2All - t1All
if totalDelta <= 0 {
return CpuMetrics{}, nil
return CpuMetrics{}
}
metrics := CpuMetrics{
@@ -73,8 +81,7 @@ func getCpuMetrics(cacheTimeMs uint16, forceUseCgroup bool) (CpuMetrics, error)
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
}
lastCpuTimes[cacheTimeMs] = times[0]
return metrics, nil
return metrics
}
// clampPercent ensures the percentage is between 0 and 100
+57
View File
@@ -26,3 +26,60 @@ func TestGetAllBusy(t *testing.T) {
assert.Zero(t, calculateBusy(times, times))
assert.Zero(t, calculateBusy(times, cpu.TimesStat{}))
}
func TestCpuMetricsFromTimesRecoversAfterCounterRollback(t *testing.T) {
const cacheTimeMs uint16 = 1234
saved := lastCpuTimes
t.Cleanup(func() { lastCpuTimes = saved })
for _, tt := range []struct {
name string
previous cpu.TimesStat
current cpu.TimesStat
next cpu.TimesStat
}{
{
name: "all counters reset",
previous: cpu.TimesStat{User: 1000, System: 500, Idle: 8500},
current: cpu.TimesStat{User: 10, System: 5, Idle: 85},
next: cpu.TimesStat{User: 20, System: 10, Idle: 170},
},
{
// TimesStat uses seconds: a 32-bit counter at 100 Hz wraps at 42949672.96.
name: "idle counter wraps",
previous: cpu.TimesStat{User: 100, System: 50, Idle: 42949672.90},
current: cpu.TimesStat{User: 110, System: 55, Idle: 0.50},
next: cpu.TimesStat{User: 120, System: 60, Idle: 85.50},
},
} {
t.Run(tt.name, func(t *testing.T) {
lastCpuTimes = map[uint16]cpu.TimesStat{cacheTimeMs: tt.previous}
assert.Equal(t, CpuMetrics{}, cpuMetricsFromTimes(cacheTimeMs, tt.current))
assert.Equal(t, tt.current, lastCpuTimes[cacheTimeMs], "a rollback must establish a new baseline")
metrics := cpuMetricsFromTimes(cacheTimeMs, tt.next)
assert.InDelta(t, 15, metrics.Total, 1e-10)
assert.InDelta(t, 10, metrics.User, 1e-10)
assert.InDelta(t, 5, metrics.System, 1e-10)
assert.InDelta(t, 85, metrics.Idle, 1e-10)
})
}
}
func TestCpuMetricsFromTimesCacheIntervals(t *testing.T) {
saved := lastCpuTimes
t.Cleanup(func() { lastCpuTimes = saved })
baseline := cpu.TimesStat{User: 10, System: 5, Idle: 85}
lastCpuTimes = map[uint16]cpu.TimesStat{60000: baseline}
// A new cache interval uses the default interval's baseline.
current := cpu.TimesStat{User: 20, System: 10, Idle: 170}
metrics := cpuMetricsFromTimes(1234, current)
assert.InDelta(t, 15, metrics.Total, 1e-10)
assert.Equal(t, baseline, lastCpuTimes[60000], "intervals must keep independent baselines")
assert.Equal(t, current, lastCpuTimes[1234])
// An unchanged sample must not divide by zero or stop later sampling.
assert.Equal(t, CpuMetrics{}, cpuMetricsFromTimes(1234, current))
metrics = cpuMetricsFromTimes(1234, cpu.TimesStat{User: 30, System: 15, Idle: 255})
assert.InDelta(t, 15, metrics.Total, 1e-10)
}
+14 -2
View File
@@ -326,8 +326,8 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
recvTracker.Set(ctr.IdShort, total_recv)
// Get deltas (bytes since last measurement)
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
sent_delta_raw := counterDelta(sentTracker, ctr.IdShort, total_sent)
recv_delta_raw := counterDelta(recvTracker, ctr.IdShort, total_recv)
// Calculate bytes per second using per-cache-time read time to avoid
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
@@ -355,6 +355,18 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
return sent_delta, recv_delta
}
// counterDelta returns the increase of a cumulative counter since the previous cycle.
// If the counter went down, 0 is returned for this cycle. The counter is summed across
// the container's interfaces, so a decrease may be a restart (all counters reset) or a
// removed interface (others keep their totals), and the current value isn't a safe delta.
func counterDelta(tracker *deltatracker.DeltaTracker[string, uint64], id string, current uint64) uint64 {
prev, ok := tracker.Previous(id)
if !ok || current < prev {
return 0
}
return current - prev
}
// validateCpuPercentage checks if CPU percentage is within valid range
func validateCpuPercentage(cpuPct float64, containerName string) error {
if cpuPct > 100 {
+58
View File
@@ -1279,6 +1279,64 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
assert.Equal(t, uint64(0), recv3)
}
func TestNetworkStatsCounterDecreaseDoesNotUnderflow(t *testing.T) {
tests := []struct {
name string
before map[string]container.NetworkStats
after map[string]container.NetworkStats
}{
{
name: "container restarted",
before: map[string]container.NetworkStats{"eth0": {TxBytes: 5_000_000, RxBytes: 8_000_000}},
after: map[string]container.NetworkStats{"eth0": {TxBytes: 2_000, RxBytes: 3_000}},
},
{
name: "interface removed",
before: map[string]container.NetworkStats{
"eth0": {TxBytes: 1_000_000_000, RxBytes: 2_000_000_000},
"eth1": {TxBytes: 500_000_000, RxBytes: 500_000_000},
},
after: map[string]container.NetworkStats{"eth0": {TxBytes: 1_010_000_000, RxBytes: 2_010_000_000}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
ctr := &container.ApiInfo{IdShort: "test-container"}
cacheTimeMs := uint16(60000)
nextCycle := func() {
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
dm.lastNetworkReadTime[cacheTimeMs] = map[string]time.Time{
"test-container": time.Now().Add(-1000 * time.Millisecond),
}
}
_, _ = dm.calculateNetworkStats(ctr, &container.ApiStats{Networks: tt.before}, "test", cacheTimeMs)
nextCycle()
// Summed counters went down: skip this cycle rather than wrap around or report a spike
sent, recv := dm.calculateNetworkStats(ctr, &container.ApiStats{Networks: tt.after}, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent)
assert.Equal(t, uint64(0), recv)
// Next cycle measures from the new baseline
nextCycle()
grown := make(map[string]container.NetworkStats, len(tt.after))
for name, v := range tt.after {
grown[name] = container.NetworkStats{TxBytes: v.TxBytes + 1_000, RxBytes: v.RxBytes + 2_000}
}
sent, recv = dm.calculateNetworkStats(ctr, &container.ApiStats{Networks: grown}, "test", cacheTimeMs)
assert.InDelta(t, 1_000, sent, 10)
assert.InDelta(t, 2_000, recv, 20)
})
}
}
func TestContainerStatsEndToEndWithRealData(t *testing.T) {
// Load minimal container stats
data, err := os.ReadFile("test-data/container.json")
+91 -23
View File
@@ -3,6 +3,7 @@ package agent
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log/slog"
"os/exec"
@@ -93,37 +94,37 @@ func (gm *GPUManager) collectIntelStats() (err error) {
// parseIntelJSONStream decodes samples from intel_gpu_top -J output and
// aggregates them. Since v1.28 the samples are wrapped in an array ("[", then
// comma separated objects, and "]" only when the process exits). Older
// versions print the same comma separated objects without the opening "[", so
// it is added here to let both formats decode as an array.
// objects, and "]" only when the process exits). Older versions print objects
// without the opening "[". Some builds (seen on i915 / Arrow Lake) omit the
// commas between objects inside the array; those are tolerated by reading
// successive objects rather than decoding the whole stream as one JSON array.
func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
er := &eofReader{r: r}
br := bufio.NewReader(er)
first, err := peekNonSpace(br)
if err != nil {
if err == io.EOF {
return errNoValidData
}
return err
}
var src io.Reader = br
if first != '[' {
src = io.MultiReader(strings.NewReader("["), br)
}
dec := json.NewDecoder(src)
if _, err := dec.Token(); err != nil { // opening "["
return err
}
var hadDataRow bool
// skip first data row because it sometimes has erroneous data
var skippedFirstDataRow bool
// Decode reads one object and skips the commas between them. The array is
// usually never closed, so output ending mid-array or mid-sample (the
// process was killed) is the normal end of the stream rather than an error.
for dec.More() {
// buf is reused across samples; json.Unmarshal does not retain its input.
var buf []byte
for {
obj, err := nextIntelJSONObject(br, buf)
if err != nil {
if err == io.EOF {
break
}
// A truncated final sample when the process is killed is normal.
if er.eof {
break
}
return err
}
if obj == nil { // closing "]"
break
}
buf = obj
var sample intelGpuJSONSample
if err := dec.Decode(&sample); err != nil {
if err := json.Unmarshal(obj, &sample); err != nil {
if er.eof {
break
}
@@ -147,6 +148,73 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
return nil
}
// nextIntelJSONObject returns the next top-level JSON object from br, skipping
// an optional opening "[", commas, and whitespace. A closing "]" returns
// (nil, nil). EOF with no further object returns (nil, io.EOF). The object is
// appended to buf[:0], so the returned slice may share buf's backing array.
func nextIntelJSONObject(br *bufio.Reader, buf []byte) ([]byte, error) {
for {
b, err := peekNonSpace(br)
if err != nil {
return nil, err
}
switch b {
case '[', ',':
_, _ = br.ReadByte()
continue
case ']':
_, _ = br.ReadByte()
return nil, nil
case '{':
return readJSONObject(br, buf[:0])
default:
return nil, fmt.Errorf("invalid character %q looking for beginning of intel_gpu_top sample", b)
}
}
}
// readJSONObject appends one JSON object from br to buf, respecting strings
// and nesting.
func readJSONObject(br *bufio.Reader, buf []byte) ([]byte, error) {
depth := 0
inString := false
escape := false
for {
c, err := br.ReadByte()
if err != nil {
if err == io.EOF {
return nil, io.ErrUnexpectedEOF
}
return nil, err
}
buf = append(buf, c)
if inString {
if escape {
escape = false
continue
}
switch c {
case '\\':
escape = true
case '"':
inString = false
}
continue
}
switch c {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return buf, nil
}
}
}
}
// eofReader records whether the underlying reader has returned io.EOF. The
// json decoder reports a stream ending mid-value as a syntax error, so this
// is how a truncated final sample is told apart from invalid output.
+88 -3
View File
@@ -5,6 +5,7 @@ package agent
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"unsafe"
@@ -15,7 +16,9 @@ import (
// NVML constants and types
const (
nvmlSuccess int = 0
nvmlSuccess int = 0
nvmlErrorNotFound nvmlReturn = 6
nvmlTotalPowerSamples int = 0
)
type nvmlDevice uintptr
@@ -41,6 +44,12 @@ type nvmlUtilization struct {
Memory uint32
}
// nvmlSample mirrors nvmlSample_t: a timestamp plus an 8-byte nvmlValue_t union
type nvmlSample struct {
TimeStamp uint64
SampleValue uint64
}
type nvmlPciInfo struct {
BusId [16]byte
Domain uint32
@@ -62,6 +71,7 @@ var (
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
nvmlDeviceGetSamples func(device nvmlDevice, sampleType int, lastSeenTimeStamp uint64, sampleValType *int32, sampleCount *uint32, samples *nvmlSample) nvmlReturn
nvmlErrorString func(result nvmlReturn) string
)
@@ -71,6 +81,9 @@ type nvmlCollector struct {
devices []nvmlDevice
bdfs []string
isV2 bool
// per-device state for the power sample fallback
lastSampleTs []uint64
lastPower []float64
}
func (c *nvmlCollector) init() error {
@@ -100,6 +113,9 @@ func (c *nvmlCollector) init() error {
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
if hasSymbol(lib, "nvmlDeviceGetSamples") {
purego.RegisterLibFunc(&nvmlDeviceGetSamples, lib, "nvmlDeviceGetSamples")
}
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
return fmt.Errorf("nvmlInit failed: %v", ret)
@@ -125,6 +141,8 @@ func (c *nvmlCollector) init() error {
} else {
c.bdfs = append(c.bdfs, "")
}
c.lastSampleTs = append(c.lastSampleTs, 0)
c.lastPower = append(c.lastPower, 0)
}
}
@@ -213,12 +231,79 @@ func (c *nvmlCollector) collect() {
// Power
var power uint32
nvmlDeviceGetPowerUsage(device, &power)
powerWatts := 0.0
if ret := nvmlDeviceGetPowerUsage(device, &power); ret == nvmlReturn(nvmlSuccess) {
powerWatts = float64(power) / 1000.0
} else {
// Some GPUs / drivers (e.g. RTX 4060 on 580.x) report power.draw as N/A
// but still expose the driver's power sample buffer
powerWatts = c.powerFromSamples(i, device)
}
gpu.Temperature = float64(temp)
gpu.Usage += float64(utilization.Gpu)
gpu.Power += float64(power) / 1000.0
gpu.Power += powerWatts
gpu.Count++
slog.Debug("NVML: Collected data", "gpu", gpu)
}
}
// powerFromSamples returns the average power in watts of the NVML power samples
// recorded since the previous call, or the last known value if there are none.
func (c *nvmlCollector) powerFromSamples(i int, device nvmlDevice) float64 {
if nvmlDeviceGetSamples == nil {
return 0
}
var valType int32
var count uint32
// first call with nil buffer returns the maximum number of samples
if ret := nvmlDeviceGetSamples(device, nvmlTotalPowerSamples, c.lastSampleTs[i], &valType, &count, nil); ret != nvmlReturn(nvmlSuccess) || count == 0 {
if ret != nvmlErrorNotFound {
slog.Debug("NVML: power samples unavailable", "ret", ret)
}
return c.lastPower[i]
}
samples := make([]nvmlSample, count)
if ret := nvmlDeviceGetSamples(device, nvmlTotalPowerSamples, c.lastSampleTs[i], &valType, &count, &samples[0]); ret != nvmlReturn(nvmlSuccess) || count == 0 {
if ret != nvmlErrorNotFound {
slog.Debug("NVML: power samples failed", "ret", ret)
}
return c.lastPower[i]
}
var sum float64
var n int
for _, s := range samples[:count] {
v, ok := nvmlSampleToFloat(valType, s.SampleValue)
if !ok {
continue
}
sum += v
n++
if s.TimeStamp > c.lastSampleTs[i] {
c.lastSampleTs[i] = s.TimeStamp
}
}
if n > 0 {
c.lastPower[i] = sum / float64(n) / 1000.0 // milliwatts -> watts
}
return c.lastPower[i]
}
// nvmlSampleToFloat decodes an nvmlValue_t union according to its nvmlValueType_t
func nvmlSampleToFloat(valType int32, raw uint64) (float64, bool) {
switch valType {
case 0: // NVML_VALUE_TYPE_DOUBLE
return math.Float64frombits(raw), true
case 1: // NVML_VALUE_TYPE_UNSIGNED_INT
return float64(uint32(raw)), true
case 2, 3: // NVML_VALUE_TYPE_UNSIGNED_LONG, NVML_VALUE_TYPE_UNSIGNED_LONG_LONG
return float64(raw), true
case 4: // NVML_VALUE_TYPE_SIGNED_LONG_LONG
return float64(int64(raw)), true
case 5: // NVML_VALUE_TYPE_SIGNED_INT
return float64(int32(uint32(raw))), true
case 6: // NVML_VALUE_TYPE_UNSIGNED_SHORT
return float64(uint16(raw)), true
}
return 0, false
}
+94
View File
@@ -0,0 +1,94 @@
//go:build testing && amd64 && (windows || (linux && glibc))
package agent
import (
"math"
"testing"
"unsafe"
)
func TestNvmlSampleToFloat(t *testing.T) {
tests := []struct {
valType int32
raw uint64
want float64
ok bool
}{
{0, math.Float64bits(55.5), 55.5, true},
{1, 0xdeadbeef_0000d878, 55416, true}, // upper union bytes ignored for uint32
{3, 115000, 115000, true},
{4, uint64(1<<64 - 5), -5, true},
{5, 0x00000000_fffffffb, -5, true},
{6, 0xffff_0064, 100, true},
{99, 1, 0, false},
}
for _, tt := range tests {
got, ok := nvmlSampleToFloat(tt.valType, tt.raw)
if got != tt.want || ok != tt.ok {
t.Errorf("nvmlSampleToFloat(%d, %#x) = %v, %v; want %v, %v", tt.valType, tt.raw, got, ok, tt.want, tt.ok)
}
}
}
func TestPowerFromSamples(t *testing.T) {
buf := []nvmlSample{{100, 110000}, {200, 120000}, {300, 130000}}
var seenTs []uint64
nvmlDeviceGetSamples = func(_ nvmlDevice, _ int, last uint64, valType *int32, count *uint32, samples *nvmlSample) nvmlReturn {
seenTs = append(seenTs, last)
var out []nvmlSample
for _, s := range buf {
if s.TimeStamp > last {
out = append(out, s)
}
}
if len(out) == 0 {
return nvmlErrorNotFound
}
*valType = 1 // NVML_VALUE_TYPE_UNSIGNED_INT
if samples == nil {
*count = 120 // driver buffer size
return nvmlReturn(nvmlSuccess)
}
*count = uint32(copy(unsafe.Slice(samples, *count), out))
return nvmlReturn(nvmlSuccess)
}
defer func() { nvmlDeviceGetSamples = nil }()
c := &nvmlCollector{lastSampleTs: []uint64{0}, lastPower: []float64{0}}
// first poll averages the whole buffer
if got := c.powerFromSamples(0, 0); got != 120 {
t.Fatalf("first poll = %v; want 120", got)
}
// no new samples reuses the last value
if got := c.powerFromSamples(0, 0); got != 120 {
t.Fatalf("poll without new samples = %v; want 120", got)
}
// only samples newer than the last seen timestamp are averaged
buf = append(buf, nvmlSample{400, 50000})
if got := c.powerFromSamples(0, 0); got != 50 {
t.Fatalf("poll with new sample = %v; want 50", got)
}
wantTs := []uint64{0, 0, 300, 300, 300}
if len(seenTs) != len(wantTs) {
t.Fatalf("lastSeenTimeStamp args = %v; want %v", seenTs, wantTs)
}
for i := range wantTs {
if seenTs[i] != wantTs[i] {
t.Fatalf("lastSeenTimeStamp args = %v; want %v", seenTs, wantTs)
}
}
if c.lastSampleTs[0] != 400 {
t.Fatalf("lastSampleTs = %d; want 400", c.lastSampleTs[0])
}
}
func TestPowerFromSamplesUnavailable(t *testing.T) {
nvmlDeviceGetSamples = nil
c := &nvmlCollector{lastSampleTs: []uint64{0}, lastPower: []float64{0}}
if got := c.powerFromSamples(0, 0); got != 0 {
t.Fatalf("powerFromSamples without symbol = %v; want 0", got)
}
}
+28 -1
View File
@@ -1716,14 +1716,23 @@ func intelJSONSample(powerGPU, powerPkg float64, engines map[string]float64) str
// intelJSONStream joins samples as intel_gpu_top -J prints them. Since v1.28
// the output starts with "[" (withArray); older versions omit it.
// Commas between objects are the well-formed case; some i915 builds omit them (#2587).
func intelJSONStream(withArray bool, samples ...string) string {
return intelJSONStreamSep(withArray, true, samples...)
}
func intelJSONStreamSep(withArray, withCommas bool, samples ...string) string {
var sb strings.Builder
if withArray {
sb.WriteString("[\n")
}
for i, s := range samples {
if i > 0 {
sb.WriteString(",\n")
if withCommas {
sb.WriteString(",\n")
} else {
sb.WriteString("\n")
}
}
sb.WriteString(s)
}
@@ -1803,6 +1812,24 @@ func TestParseIntelJSONStream(t *testing.T) {
wantPkg: 5,
wantEngines: classViewWant,
},
{
// Some intel_gpu_top builds wrap samples in "["..."]" but omit commas
// between objects ("}\n{"). Observed on Arrow Lake i915 (#2587).
name: "array without commas between objects",
input: intelJSONStreamSep(true, false, first, classView[0], classView[1]) + "\n]\n",
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
},
{
name: "open array without commas between objects",
input: intelJSONStreamSep(true, false, first, classView[0], classView[1]),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
},
{
name: "truncated final sample",
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
+8 -20
View File
@@ -84,9 +84,8 @@ func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats
if netIO, err := psutilNet.IOCounters(true); err == nil {
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
bytesSentPerSecond, bytesRecvPerSecond := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, bytesSentPerSecond, bytesRecvPerSecond)
}
}
@@ -154,8 +153,10 @@ func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.Net
return netIoStat, msElapsed
}
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
// sumAndTrackPerNicDeltas records per-NIC up/down deltas into systemStats and returns their summed rates.
// Summing per-NIC deltas (rather than diffing summed counters) keeps the total from underflowing
// when a tracked interface disappears or its counters reset.
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
if tracker == nil {
tracker = deltatracker.NewDeltaTracker[string, uint64]()
@@ -171,9 +172,6 @@ func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, ne
if useMacCounters {
correctNvethernetCounters(&v)
}
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv
var upDelta, downDelta uint64
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
tracker.Set(upKey, v.BytesSent)
@@ -199,17 +197,10 @@ func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, ne
}
}
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
bytesSentPerSecond += upDelta
bytesRecvPerSecond += downDelta
}
return totalBytesSent, totalBytesRecv
}
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
}
return bytesSentPerSecond, bytesRecvPerSecond
}
@@ -219,7 +210,6 @@ func (a *Agent) applyNetworkTotals(
netIO []psutilNet.IOCountersStat,
systemStats *system.Stats,
nis system.NetIoStats,
totalBytesSent, totalBytesRecv uint64,
bytesSentPerSecond, bytesRecvPerSecond uint64,
) {
if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 {
@@ -238,8 +228,6 @@ func (a *Agent) applyNetworkTotals(
}
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
nis.BytesSent = totalBytesSent
nis.BytesRecv = totalBytesRecv
a.netIoStats[cacheTimeMs] = nis
}
+35 -41
View File
@@ -327,21 +327,6 @@ func TestLoadAndTickNetBaseline(t *testing.T) {
assert.False(t, ni2.Time.IsZero())
}
func TestComputeBytesPerSecond(t *testing.T) {
a := &Agent{}
// No elapsed -> zero rate
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
assert.Equal(t, uint64(0), bytesUp)
assert.Equal(t, uint64(0), bytesDown)
// With elapsed -> per-second calculation
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
assert.Equal(t, uint64(10000), bytesUp)
assert.Equal(t, uint64(20000), bytesDown)
}
func TestSumAndTrackPerNicDeltas(t *testing.T) {
a := &Agent{
netInterfaces: map[string]bool{"eth0": false, "wlan0": false},
@@ -354,16 +339,17 @@ func TestSumAndTrackPerNicDeltas(t *testing.T) {
stats1 := &system.Stats{}
a.ensureNetworkInterfacesMap(stats1)
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
assert.Equal(t, uint64(1000), tx1)
assert.Equal(t, uint64(2000), rx1)
// No elapsed time yet -> zero rate
assert.Equal(t, uint64(0), tx1)
assert.Equal(t, uint64(0), rx1)
// Second cycle with elapsed, larger counters -> deltas computed inside
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
stats := &system.Stats{}
a.ensureNetworkInterfacesMap(stats)
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
assert.Equal(t, uint64(4000), tx2)
assert.Equal(t, uint64(9000), rx2)
assert.Equal(t, uint64(3000), tx2)
assert.Equal(t, uint64(7000), rx2)
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
ni, ok := stats.NetworkInterfaces["eth0"]
assert.True(t, ok)
@@ -408,13 +394,39 @@ func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
}
func TestSumAndTrackPerNicDeltasInterfaceDisappears(t *testing.T) {
a := &Agent{
netInterfaces: map[string]bool{"eth0": false, "wpan0": false},
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
}
cache := uint16(60000)
before := []psutilNet.IOCountersStat{
{Name: "eth0", BytesSent: 10_000_000, BytesRecv: 50_000_000},
{Name: "wpan0", BytesSent: 40_000, BytesRecv: 300_000},
}
statsBefore := &system.Stats{}
a.ensureNetworkInterfacesMap(statsBefore)
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, before, statsBefore)
// wpan0 is gone and eth0 grew less than wpan0's counters, so the summed counters drop
after := []psutilNet.IOCountersStat{
{Name: "eth0", BytesSent: 10_030_000, BytesRecv: 50_060_000},
}
statsAfter := &system.Stats{}
a.ensureNetworkInterfacesMap(statsAfter)
tx, rx := a.sumAndTrackPerNicDeltas(cache, 60_000, after, statsAfter)
assert.Equal(t, uint64(500), tx, "total should only reflect eth0 growth, not underflow")
assert.Equal(t, uint64(1_000), rx, "total should only reflect eth0 growth, not underflow")
assert.NotContains(t, statsAfter.NetworkInterfaces, "wpan0")
}
func TestApplyNetworkTotals(t *testing.T) {
tests := []struct {
name string
bytesSentPerSecond uint64
bytesRecvPerSecond uint64
totalBytesSent uint64
totalBytesRecv uint64
expectReset bool
expectedBandwidthSent uint64
expectedBandwidthRecv uint64
@@ -423,8 +435,6 @@ func TestApplyNetworkTotals(t *testing.T) {
name: "Valid network stats - normal values",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 2000000, // 2 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: false,
expectedBandwidthSent: 1000000,
expectedBandwidthRecv: 2000000,
@@ -433,32 +443,24 @@ func TestApplyNetworkTotals(t *testing.T) {
name: "Invalid network stats - sent exceeds threshold",
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
bytesRecvPerSecond: 1000000, // 1 MB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - recv exceeds threshold",
bytesSentPerSecond: 1000000, // 1 MB/s
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Invalid network stats - both exceed threshold",
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
totalBytesSent: 10000000,
totalBytesRecv: 20000000,
expectReset: true,
},
{
name: "Zero values",
bytesSentPerSecond: 0,
bytesRecvPerSecond: 0,
totalBytesSent: 0,
totalBytesRecv: 0,
expectReset: false,
expectedBandwidthSent: 0,
expectedBandwidthRecv: 0,
@@ -486,8 +488,6 @@ func TestApplyNetworkTotals(t *testing.T) {
netIO,
systemStats,
nis,
tt.totalBytesSent,
tt.totalBytesRecv,
tt.bytesSentPerSecond,
tt.bytesRecvPerSecond,
)
@@ -502,11 +502,7 @@ func TestApplyNetworkTotals(t *testing.T) {
// Should have applied stats
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
// Should have updated NetIoStats
updatedNis := a.netIoStats[cacheTimeMs]
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
assert.Contains(t, a.netIoStats, cacheTimeMs, "baseline should be stored")
}
})
}
@@ -525,8 +521,6 @@ func TestSumAndTrackPerNicDeltasKeepsCountersWhenMacReadFails(t *testing.T) {
stats := &system.Stats{}
a.ensureNetworkInterfacesMap(stats)
tx, rx := a.sumAndTrackPerNicDeltas(1, 0, netIO, stats)
assert.Equal(t, uint64(400), tx)
assert.Equal(t, uint64(600), rx)
_, _ = a.sumAndTrackPerNicDeltas(1, 0, netIO, stats)
assert.Equal(t, [4]uint64{0, 0, 300, 400}, stats.NetworkInterfaces["missing0"])
}
+90
View File
@@ -25,6 +25,9 @@ const (
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
// xbpsSyncInterval limits how often xbps-install fetches remote repository
// data. Checks in between reuse the last fetched update list.
xbpsSyncInterval = 12 * time.Hour
)
// packageUpdatesResult is the outcome of one package manager check.
@@ -156,6 +159,8 @@ func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
case commandExists("xbps-install"):
return "xbps", newXbpsCheck()
}
return "", nil
}
@@ -541,3 +546,88 @@ func splitApkNameVersion(s string) (name, version string) {
}
return s[:ver], s[ver+1:]
}
// newXbpsCheck fetches remote repository data into memory (-M) every
// xbpsSyncInterval. The system's on-disk copy is only refreshed by xbps-install -S,
// so it is often stale, and xbps cannot keep the fetched data. Checks in between
// reuse the last fetched update list and drop packages whose installed version
// changed since, so local upgrades show up right away. xbps has no security
// metadata, so the security count is omitted.
func newXbpsCheck() packageUpdatesCheck {
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
var synced []system.PackageUpdate
return func(ctx context.Context) (packageUpdatesResult, error) {
sync := lastSync.IsZero() || time.Since(lastSync) >= xbpsSyncInterval
var updates []system.PackageUpdate
if sync {
out, err := runPackageCommand(ctx, nil, "xbps-install", "-Mun")
if err != nil {
return packageUpdatesResult{}, err
}
updates = parseXbpsSimulate(out)
}
out, err := runPackageCommand(ctx, nil, "xbps-query", "-l")
if err != nil {
return packageUpdatesResult{}, err
}
installed := parseXbpsInstalled(out)
if sync {
for i := range updates {
updates[i].Current = installed[updates[i].Name]
}
synced = updates
lastSync = time.Now()
}
var packages []system.PackageUpdate
for _, pkg := range synced {
// skip packages upgraded or removed since the last sync
if ver, ok := installed[pkg.Name]; ok && ver == pkg.Current {
packages = append(packages, pkg)
}
}
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
}
// parseXbpsSimulate parses upgrades in `xbps-install -Mun` output. Upgrade lines look like
// "libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762"
func parseXbpsSimulate(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 || fields[1] != "update" {
continue
}
name, ver := parseXbpsNameVersion(fields[0])
packages = append(packages, system.PackageUpdate{Name: name, Available: ver})
}
return packages
}
// parseXbpsInstalled maps package names to installed versions from `xbps-query -l`
// output. Lines look like "ii xbps-0.60.7_1 XBPS package system utilities".
func parseXbpsInstalled(out string) map[string]string {
installed := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 2 {
continue
}
name, ver := parseXbpsNameVersion(fields[1])
installed[name] = ver
}
return installed
}
// parseXbpsNameVersion splits name-version to tuple (name, version)
// Names may contain dashes, but versions do not.
func parseXbpsNameVersion(s string) (name, ver string) {
i := strings.LastIndexByte(s, '-')
if i < 0 {
return s, ""
}
return s[:i], s[i+1:]
}
+61
View File
@@ -191,6 +191,66 @@ func TestParseRpmInstalled(t *testing.T) {
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
}
func TestParseXbpsSimulate(t *testing.T) {
packages := parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_check_update.txt"))
assert.Len(t, packages, 3)
assert.Equal(t, system.PackageUpdate{Name: "libmariadbclient", Available: "12.3.2_2"}, findPackage(t, packages, "libmariadbclient"))
packages = parseXbpsSimulate(readPackageUpdatesTestData(t, "xbps_void_install_update.txt"))
assert.Len(t, packages, 7)
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates", Available: "20250419+3.127_1"}, findPackage(t, packages, "ca-certificates"))
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Available: "3.6.5_1"}, findPackage(t, packages, "libcrypto3"))
// new dependencies are not updates
assert.Empty(t, parseXbpsSimulate("libfoo-1.0_1 install x86_64 https://repo-default.voidlinux.org/current 100 50\n"))
assert.Empty(t, parseXbpsSimulate("\n"))
}
func TestParseXbpsInstalled(t *testing.T) {
installed := parseXbpsInstalled(readPackageUpdatesTestData(t, "xbps_void_query_installed.txt"))
assert.Len(t, installed, 29)
assert.Equal(t, "0.60.7_1", installed["xbps"])
assert.Equal(t, "0.131_1", installed["xbps-triggers"])
assert.Equal(t, "20250419+3.127_1", installed["ca-certificates"])
}
func TestXbpsCheckSync(t *testing.T) {
binDir := t.TempDir()
logFile := filepath.Join(binDir, "calls.log")
installedFile := filepath.Join(binDir, "installed.txt")
writeInstalled := func(lines string) {
require.NoError(t, os.WriteFile(installedFile, []byte(lines), 0o644))
}
writeInstalled("ii bash-5.2.021_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n")
fakeCommands(t, map[string]string{
"xbps-install": `echo "xbps-install $*" >> ` + logFile + `
echo "bash-5.3.9_1 update x86_64 https://repo-default.voidlinux.org/current 100 50"
echo "libgbm-26.2.4_1 update x86_64 https://repo-default.voidlinux.org/current 100 50"`,
"xbps-query": `cat ` + installedFile,
})
check := newXbpsCheck()
// first check fetches remote data
result, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{2}, result.counts)
assert.False(t, result.securityKnown)
assert.Equal(t, []system.PackageUpdate{
{Name: "bash", Current: "5.2.021_1", Available: "5.3.9_1"},
{Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"},
}, result.packages)
// later checks reuse the fetched list and drop locally upgraded packages
writeInstalled("ii bash-5.3.9_1 GNU Bourne Again Shell\nii libgbm-26.2.3_1 Mesa gbm library\n")
result, err = check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "libgbm", Current: "26.2.3_1", Available: "26.2.4_1"}}, result.packages)
data, err := os.ReadFile(logFile)
require.NoError(t, err)
assert.Equal(t, "xbps-install -Mun\n", string(data))
}
func TestCheckDnf(t *testing.T) {
tests := []struct {
name, updates, security, installed string
@@ -279,6 +339,7 @@ func TestDetectPackageManager(t *testing.T) {
{"zypper-aptitude", map[string]string{"apt-get": "", "zypper": ""}, "zypper"},
{"zypper-aptitude with dpkg", map[string]string{"apt-get": "", "dpkg-query": "exit 1", "zypper": ""}, "zypper"},
{"dnf", map[string]string{"dnf": "", "rpm": ""}, "dnf"},
{"xbps", map[string]string{"xbps-install": ""}, "xbps"},
{"none", nil, ""},
}
for _, tt := range tests {
+179 -229
View File
@@ -31,6 +31,7 @@ type SmartManager struct {
lastScanTime time.Time
smartctlPath string
excludedDevices map[string]struct{}
nvmeNamespacePaths map[string]string // controller to working namespace; guarded by Mutex
darwinNvmeOnce sync.Once
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
darwinNvmeProvider func() ([]byte, error) // overridable for testing
@@ -107,14 +108,6 @@ func (sm *SmartManager) devicesSnapshot() []*DeviceInfo {
return devices
}
// hasSmartData reports whether any SMART data has been collected.
// func (sm *SmartManager) hasSmartData() bool {
// sm.Lock()
// defer sm.Unlock()
// return len(sm.SmartDataMap) > 0
// }
// resolveRefreshError determines the proper error to return after a refresh.
func (sm *SmartManager) resolveRefreshError(scanErr, collectErr error) error {
sm.Lock()
@@ -216,6 +209,8 @@ func (sm *SmartManager) ScanDevices(force bool) error {
hasValidScan = true
}
scannedDevices = sm.resolveNvmeNamespacePaths(scannedDevices)
configuredDevices = sm.resolveNvmeNamespacePaths(configuredDevices)
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices)
@@ -231,6 +226,39 @@ func (sm *SmartManager) ScanDevices(force bool) error {
return nil
}
// resolveNvmeNamespacePaths keeps successful namespace fallbacks when a later
// scan (or SMART_DEVICES) reports the original controller path again. Preserve
// the controller's presence in the scan so removed drives can still be pruned.
func (sm *SmartManager) resolveNvmeNamespacePaths(devices []*DeviceInfo) []*DeviceInfo {
sm.Lock()
defer sm.Unlock()
if len(sm.nvmeNamespacePaths) == 0 {
return devices
}
resolved := make([]*DeviceInfo, 0, len(devices))
seen := make(map[deviceKey]struct{}, len(devices))
for _, device := range devices {
if device == nil {
continue
}
// Keep excluded controller entries unchanged until filterExcludedDevices
// runs, so an empty input cannot make mergeDeviceLists retain old devices.
if namespace := sm.nvmeNamespacePaths[device.Name]; namespace != "" && !sm.isExcludedDevice(device.Name) {
copyDevice := *device
copyDevice.Name = namespace
device = &copyDevice
}
key := makeDeviceKey(device.Name, device.Type)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
resolved = append(resolved, device)
}
return resolved
}
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
if splitChar == "" {
@@ -499,8 +527,6 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return errNoValidSmartData
}
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo))
// Check if we have existing data for this exact device identity. Multiple
// bridge slots can share a path, so a name-only match is not sufficient.
hasExistingData := sm.hasDataForDevice(deviceInfo)
@@ -513,8 +539,8 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
cmd := exec.CommandContext(ctx, sm.smartctlPath, args...)
output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2)
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
// NVMe has no standby check; exit status 2 may mean the controller is inaccessible.
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 && !isNvmeControllerPath(deviceInfo.Name) {
if hasExistingData {
// Device is in standby and we have cached data, keep using cache
return nil
@@ -535,24 +561,26 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
controllerPath := deviceInfo.Name
namespacePath := controllerPath + "n1"
if !sm.isExcludedDevice(namespacePath) {
deviceInfo.Name = namespacePath
namespaceDevice := *deviceInfo
namespaceDevice.Name = namespacePath
ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel3()
args = sm.smartctlArgs(deviceInfo, false)
args = sm.smartctlArgs(&namespaceDevice, false)
cmd = exec.CommandContext(ctx3, sm.smartctlPath, args...)
output, err = cmd.CombinedOutput()
hasValidData = sm.parseSmartOutput(deviceInfo, output)
hasValidData = sm.parseSmartOutput(&namespaceDevice, output)
// Auto-exclude the controller path so future scans don't re-add it
// Resolve future controller scan results to this working namespace.
if hasValidData {
sm.Lock()
if sm.excludedDevices == nil {
sm.excludedDevices = make(map[string]struct{})
*deviceInfo = namespaceDevice
if sm.nvmeNamespacePaths == nil {
sm.nvmeNamespacePaths = make(map[string]string)
}
sm.excludedDevices[controllerPath] = struct{}{}
sm.nvmeNamespacePaths[controllerPath] = namespacePath
sm.Unlock()
slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
slog.Debug("using NVMe namespace path", "controller", controllerPath, "namespace", namespacePath)
}
}
}
@@ -802,10 +830,6 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
sm.SmartDevices = devices
if len(sm.SmartDataMap) == 0 {
return
}
validKeys := make(map[deviceKey]struct{}, len(devices))
nameCounts := make(map[string]int, len(devices))
for _, device := range devices {
@@ -816,6 +840,14 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
nameCounts[device.Name]++
}
// Forget aliases when their device disappears, so a replacement controller
// can be queried normally instead of inheriting an old namespace fallback.
for controller, namespace := range sm.nvmeNamespacePaths {
if nameCounts[controller] == 0 && nameCounts[namespace] == 0 {
delete(sm.nvmeNamespacePaths, controller)
}
}
for key, data := range sm.SmartDataMap {
if data == nil {
delete(sm.SmartDataMap, key)
@@ -835,124 +867,110 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
}
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool {
vendorUpper := strings.ToUpper(data.ScsiVendor)
productUpper := strings.ToUpper(data.ScsiProduct)
modelUpper := strings.ToUpper(data.ModelName)
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
}
// isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool {
modelUpper := strings.ToUpper(data.ModelName)
return sm.isVirtualDeviceFromStrings(modelUpper)
}
// isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool {
vendorUpper := strings.ToUpper(data.ScsiVendor)
productUpper := strings.ToUpper(data.ScsiProduct)
modelUpper := strings.ToUpper(data.ScsiModelName)
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
}
// isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device
func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
for _, field := range fields {
fieldUpper := strings.ToUpper(field)
// (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
func isVirtualDevice(data *smart.SmartctlOutput) bool {
for _, field := range []string{data.ScsiVendor, data.ScsiProduct, data.ModelName, data.ScsiModelName} {
field = strings.ToUpper(field)
switch {
case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target
strings.Contains(fieldUpper, "VIRTUAL"),
strings.Contains(fieldUpper, "QEMU"),
strings.Contains(fieldUpper, "VBOX"),
strings.Contains(fieldUpper, "VMWARE"),
strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V
case strings.Contains(field, "IET"), // iSCSI Enterprise Target
strings.Contains(field, "VIRTUAL"),
strings.Contains(field, "QEMU"),
strings.Contains(field, "VBOX"),
strings.Contains(field, "VMWARE"),
strings.Contains(field, "MSFT"): // Microsoft Hyper-V
return true
}
}
return false
}
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
var data smart.SmartInfoForSata
// decodeSmartctlOutput unmarshals smartctl JSON output and rejects devices
// without a serial number and virtual devices. It also returns the smartctl exit status.
func decodeSmartctlOutput(output []byte) (*smart.SmartctlOutput, int, bool) {
var data smart.SmartctlOutput
if err := json.Unmarshal(output, &data); err != nil {
return false, 0
slog.Debug("failed to parse smartctl output", "err", err)
return nil, 0, false
}
exitStatus := data.Smartctl.ExitStatus
if data.SerialNumber == "" {
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
return nil, exitStatus, false
}
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDevice(&data) {
if isVirtualDevice(&data) {
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
return false, data.Smartctl.ExitStatus
return nil, exitStatus, false
}
return &data, exitStatus, true
}
sm.Lock()
defer sm.Unlock()
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
if _, ok := sm.SmartDataMap[keyName]; !ok {
sm.SmartDataMap[keyName] = &smart.SmartData{}
// newSmartData fills the fields shared by all smartctl protocols. deviceType is
// the exact type used to identify and query the device; when set, it takes
// precedence over the generic type reported by smartctl.
func newSmartData(data *smart.SmartctlOutput, deviceType, model, firmware string, temperature int64) *smart.SmartData {
temp := uint8(min(max(temperature, 0), 255))
smartData := &smart.SmartData{
ModelName: model,
SerialNumber: data.SerialNumber,
FirmwareVersion: firmware,
Capacity: data.UserCapacity.Bytes,
Temperature: temp,
SmartStatus: getSmartStatus(temp, data.SmartStatus.Passed),
DiskName: data.Device.Name,
DiskType: data.Device.Type,
}
// update SmartData
smartData := sm.SmartDataMap[keyName]
// smartData.ModelFamily = data.ModelFamily
smartData.ModelName = data.ModelName
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
return smartData
}
// storeSmartData saves parsed smartctl data, keyed by serial number.
func (sm *SmartManager) storeSmartData(smartData *smart.SmartData) {
sm.Lock()
defer sm.Unlock()
sm.SmartDataMap[smartData.SerialNumber] = smartData
}
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
data, exitStatus, ok := decodeSmartctlOutput(output)
if !ok {
return false, exitStatus
}
smartData := newSmartData(data, deviceType, data.ModelName, data.FirmwareVersion, data.Temperature.Current)
// get values from ata_device_statistics if necessary
var ataDeviceStats smart.AtaDeviceStatistics
if smartData.Temperature == 0 {
if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
if temp := findAtaDeviceStatisticsValue(data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
smartData.Temperature = uint8(*temp)
}
}
// update SmartAttributes
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
for _, attr := range data.AtaSmartAttributes.Table {
rawValue := uint64(attr.Raw.Value)
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
rawValue = parsed
}
smartAttr := &smart.SmartAttribute{
var table []smart.AtaSmartAttribute
if data.AtaSmartAttributes != nil {
table = data.AtaSmartAttributes.Table
}
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(table))
for _, attr := range table {
smartData.Attributes = append(smartData.Attributes, &smart.SmartAttribute{
ID: attr.ID,
Name: attr.Name,
Value: attr.Value,
Worst: attr.Worst,
Threshold: attr.Thresh,
RawValue: rawValue,
RawValue: uint64(attr.Raw.Value), // already parsed from raw.string when possible
RawString: attr.Raw.String,
WhenFailed: attr.WhenFailed,
}
smartData.Attributes = append(smartData.Attributes, smartAttr)
})
}
sm.SmartDataMap[keyName] = smartData
return true, data.Smartctl.ExitStatus
sm.storeSmartData(smartData)
return true, exitStatus
}
func getSmartStatus(temperature uint8, passed bool) string {
@@ -967,7 +985,7 @@ func getSmartStatus(temperature uint8, passed bool) string {
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
func findAtaDeviceStatisticsValue(data *smart.SmartctlOutput, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
if len(ataDeviceStats.Pages) == 0 {
if len(data.AtaDeviceStatistics) == 0 {
return nil
@@ -995,81 +1013,48 @@ func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *
return nil
}
// parseSmartForScsi parses the output of smartctl --all -j for SCSI devices and updates the SmartDataMap.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) {
var data smart.SmartInfoForScsi
if err := json.Unmarshal(output, &data); err != nil {
return false, 0
data, exitStatus, ok := decodeSmartctlOutput(output)
if !ok {
return false, exitStatus
}
if data.SerialNumber == "" {
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
smartData := newSmartData(data, deviceType, data.ScsiModelName, data.ScsiRevision, data.Temperature.Current)
cycles := data.ScsiStartStopCycleCounter
attributes := []*smart.SmartAttribute{
{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours},
{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes},
{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList},
{Name: "StartStopCycles", RawValue: cycles.AccumulatedStartStopCycles},
{Name: "LoadUnloadCycles", RawValue: cycles.AccumulatedLoadUnloadCycles},
{Name: "StartStopSpecified", RawValue: cycles.SpecifiedCycleCountOverDeviceLifetime},
{Name: "LoadUnloadSpecified", RawValue: cycles.SpecifiedLoadUnloadCountOverDeviceLifetime},
}
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDeviceScsi(&data) {
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName)
return false, data.Smartctl.ExitStatus
var errorLog smart.ScsiErrorCounterLog
if data.ScsiErrorCounterLog != nil {
errorLog = *data.ScsiErrorCounterLog
}
sm.Lock()
defer sm.Unlock()
keyName := data.SerialNumber
if _, ok := sm.SmartDataMap[keyName]; !ok {
sm.SmartDataMap[keyName] = &smart.SmartData{}
for _, counter := range []struct {
prefix string
stats smart.ScsiErrorCounter
}{{"Read", errorLog.Read}, {"Write", errorLog.Write}, {"Verify", errorLog.Verify}} {
attributes = append(attributes,
&smart.SmartAttribute{Name: counter.prefix + "TotalErrorsCorrected", RawValue: counter.stats.TotalErrorsCorrected},
&smart.SmartAttribute{Name: counter.prefix + "TotalUncorrectedErrors", RawValue: counter.stats.TotalUncorrectedErrors},
&smart.SmartAttribute{Name: counter.prefix + "CorrectionAlgorithmInvocations", RawValue: counter.stats.CorrectionAlgorithmInvocations},
)
if val := parseScsiGigabytesProcessed(counter.stats.GigabytesProcessed); val >= 0 {
attributes = append(attributes, &smart.SmartAttribute{Name: counter.prefix + "GigabytesProcessed", RawValue: uint64(val)})
}
}
smartData := sm.SmartDataMap[keyName]
smartData.ModelName = data.ScsiModelName
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.ScsiRevision
smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
attributes := make([]*smart.SmartAttribute, 0, 10)
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes})
attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList})
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles})
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles})
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime})
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime})
readStats := data.ScsiErrorCounterLog.Read
writeStats := data.ScsiErrorCounterLog.Write
verifyStats := data.ScsiErrorCounterLog.Verify
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected})
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors})
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations})
if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 {
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)})
}
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected})
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors})
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations})
if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 {
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)})
}
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected})
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors})
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations})
if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 {
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)})
}
smartData.Attributes = attributes
sm.SmartDataMap[keyName] = smartData
return true, data.Smartctl.ExitStatus
sm.storeSmartData(smartData)
return true, exitStatus
}
func parseScsiGigabytesProcessed(value string) int64 {
@@ -1135,21 +1120,9 @@ func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) {
data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil {
return false, 0
}
if data.SerialNumber == "" {
slog.Debug("no serial number", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
if sm.isVirtualDeviceNvme(data) {
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
return false, data.Smartctl.ExitStatus
data, exitStatus, ok := decodeSmartctlOutput(output)
if !ok {
return false, exitStatus
}
// smartctl may return device identity fields before failing to read the NVMe
@@ -1159,64 +1132,41 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
log := data.NVMeSmartHealthInformationLog
if log == nil {
slog.Debug("no NVMe SMART health information", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
return false, exitStatus
}
sm.Lock()
defer sm.Unlock()
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
if _, ok := sm.SmartDataMap[keyName]; !ok {
sm.SmartDataMap[keyName] = &smart.SmartData{}
}
// update SmartData
smartData := sm.SmartDataMap[keyName]
smartData.ModelName = data.ModelName
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
smartData := newSmartData(data, deviceType, data.ModelName, data.FirmwareVersion, log.Temperature)
if smartData.Capacity == 0 {
smartData.Capacity = data.NVMeTotalCapacity
}
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Temperature = log.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
if deviceType != "" {
smartData.DiskType = deviceType
}
// nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes
smartData.Attributes = []*smart.SmartAttribute{
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
{Name: "Temperature", RawValue: uint64(log.Temperature)},
{Name: "AvailableSpare", RawValue: uint64(log.AvailableSpare)},
{Name: "AvailableSpareThreshold", RawValue: uint64(log.AvailableSpareThreshold)},
{Name: "PercentageUsed", RawValue: uint64(log.PercentageUsed)},
{Name: "CriticalWarning", RawValue: log.CriticalWarning},
{Name: "Temperature", RawValue: uint64(smartData.Temperature)},
{Name: "AvailableSpare", RawValue: log.AvailableSpare},
{Name: "AvailableSpareThreshold", RawValue: log.AvailableSpareThreshold},
{Name: "PercentageUsed", RawValue: log.PercentageUsed},
{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
{Name: "HostReads", RawValue: uint64(log.HostReads)},
{Name: "HostWrites", RawValue: uint64(log.HostWrites)},
{Name: "ControllerBusyTime", RawValue: uint64(log.ControllerBusyTime)},
{Name: "PowerCycles", RawValue: uint64(log.PowerCycles)},
{Name: "PowerOnHours", RawValue: uint64(log.PowerOnHours)},
{Name: "UnsafeShutdowns", RawValue: uint64(log.UnsafeShutdowns)},
{Name: "MediaErrors", RawValue: uint64(log.MediaErrors)},
{Name: "NumErrLogEntries", RawValue: uint64(log.NumErrLogEntries)},
{Name: "WarningTempTime", RawValue: uint64(log.WarningTempTime)},
{Name: "CriticalCompTime", RawValue: uint64(log.CriticalCompTime)},
{Name: "HostReads", RawValue: log.HostReads},
{Name: "HostWrites", RawValue: log.HostWrites},
{Name: "ControllerBusyTime", RawValue: log.ControllerBusyTime},
{Name: "PowerCycles", RawValue: log.PowerCycles},
{Name: "PowerOnHours", RawValue: log.PowerOnHours},
{Name: "UnsafeShutdowns", RawValue: log.UnsafeShutdowns},
{Name: "MediaErrors", RawValue: log.MediaErrors},
{Name: "NumErrLogEntries", RawValue: log.NumErrLogEntries},
{Name: "WarningTempTime", RawValue: log.WarningTempTime},
{Name: "CriticalCompTime", RawValue: log.CriticalCompTime},
}
sm.SmartDataMap[keyName] = smartData
return true, data.Smartctl.ExitStatus
sm.storeSmartData(smartData)
return true, exitStatus
}
// detectSmartctl checks if smartctl is installed, returns an error if not
+252 -80
View File
@@ -3,11 +3,14 @@
package agent
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
@@ -56,6 +59,11 @@ func TestParseSmartForScsi(t *testing.T) {
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
assertAttrValue(t, deviceData.Attributes, "StartStopSpecified", 50000)
assertAttrValue(t, deviceData.Attributes, "LoadUnloadSpecified", 600000)
assertAttrValue(t, deviceData.Attributes, "ReadCorrectionAlgorithmInvocations", 346)
assertAttrValue(t, deviceData.Attributes, "WriteCorrectionAlgorithmInvocations", 4052)
assertAttrValue(t, deviceData.Attributes, "VerifyCorrectionAlgorithmInvocations", 223)
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
@@ -291,6 +299,81 @@ func TestParseSmartForNvme(t *testing.T) {
}
}
// Counters larger than their old narrow Go types used to fail json.Unmarshal and
// drop the whole device (#2484, #2582).
func TestParseSmartForNvmeLargeCounters(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/nvme0", "type": "nvme"},
"model_name": "Test NVMe",
"serial_number": "NVME-LARGE",
"smart_status": {"passed": true},
"nvme_smart_health_information_log": {
"temperature": 40,
"percentage_used": 300,
"power_cycles": 70000,
"power_on_hours": 5000000000,
"unsafe_shutdowns": 159754
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForNvme(jsonPayload, "")
require.True(t, hasData)
deviceData := sm.SmartDataMap["NVME-LARGE"]
require.NotNil(t, deviceData)
assertAttrValue(t, deviceData.Attributes, "UnsafeShutdowns", 159754)
assertAttrValue(t, deviceData.Attributes, "PowerCycles", 70000)
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 5000000000)
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 300)
}
// Out-of-range temperatures are clamped instead of failing the whole parse.
func TestParseSmartTemperatureOutOfRange(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForSata([]byte(`{
"device": {"name": "/dev/sda", "type": "sat"},
"serial_number": "SATA-NEG",
"smart_status": {"passed": true},
"temperature": {"current": -5},
"ata_smart_attributes": {"table": []}
}`), "")
require.True(t, hasData)
assert.Equal(t, uint8(0), sm.SmartDataMap["SATA-NEG"].Temperature)
hasData, _ = sm.parseSmartForNvme([]byte(`{
"device": {"name": "/dev/nvme0", "type": "nvme"},
"serial_number": "NVME-HOT",
"smart_status": {"passed": true},
"nvme_smart_health_information_log": {"temperature": 300}
}`), "")
require.True(t, hasData)
assert.Equal(t, uint8(255), sm.SmartDataMap["NVME-HOT"].Temperature)
assertAttrValue(t, sm.SmartDataMap["NVME-HOT"].Attributes, "Temperature", 255)
}
func TestParseSmartForScsiWithoutErrorCounterLog(t *testing.T) {
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, _ := sm.parseSmartForScsi([]byte(`{
"device": {"name": "/dev/sdb", "type": "scsi"},
"scsi_model_name": "SEAGATE ST4000NM0023",
"serial_number": "SCSI-NOLOG",
"smart_status": {"passed": true},
"temperature": {"current": 30},
"power_on_time": {"hours": 1234}
}`), "")
require.True(t, hasData)
deviceData := sm.SmartDataMap["SCSI-NOLOG"]
require.NotNil(t, deviceData)
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 1234)
assertAttrValue(t, deviceData.Attributes, "ReadTotalUncorrectedErrors", 0)
assert.Nil(t, findAttr(deviceData.Attributes, "ReadGigabytesProcessed"))
}
func TestHasDataForDevice(t *testing.T) {
sm := &SmartManager{
SmartDataMap: map[string]*smart.SmartData{
@@ -916,91 +999,29 @@ func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttri
}
func TestIsVirtualDevice(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
data smart.SmartctlOutput
expected bool
}{
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
{"regular sata", smart.SmartctlOutput{ScsiVendor: "SEAGATE", ScsiProduct: "ST1000DM003", ModelName: "ST1000DM003-1CH162"}, false},
{"qemu sata", smart.SmartctlOutput{ScsiVendor: "QEMU", ScsiProduct: "QEMU HARDDISK", ModelName: "QEMU HARDDISK"}, true},
{"virtualbox sata", smart.SmartctlOutput{ScsiVendor: "VBOX", ScsiProduct: "HARDDISK", ModelName: "VBOX HARDDISK"}, true},
{"vmware sata", smart.SmartctlOutput{ScsiVendor: "VMWARE", ScsiProduct: "Virtual disk", ModelName: "VMWARE Virtual disk"}, true},
{"virtual in model", smart.SmartctlOutput{ScsiVendor: "ATA", ScsiProduct: "VIRTUAL", ModelName: "VIRTUAL DISK"}, true},
{"iet virtual", smart.SmartctlOutput{ScsiVendor: "IET", ScsiProduct: "VIRTUAL-DISK", ModelName: "VIRTUAL-DISK"}, true},
{"hyper-v virtual", smart.SmartctlOutput{ScsiVendor: "MSFT", ScsiProduct: "VIRTUAL HD", ModelName: "VIRTUAL HD"}, true},
{"regular nvme", smart.SmartctlOutput{ModelName: "Samsung SSD 970 EVO Plus 1TB"}, false},
{"qemu nvme", smart.SmartctlOutput{ModelName: "QEMU NVMe Ctrl"}, true},
{"virtual nvme", smart.SmartctlOutput{ModelName: "Virtual NVMe Device"}, true},
{"regular scsi", smart.SmartctlOutput{ScsiVendor: "SEAGATE", ScsiProduct: "ST1000DM003", ScsiModelName: "ST1000DM003-1CH162"}, false},
{"vmware scsi", smart.SmartctlOutput{ScsiVendor: "VMWARE", ScsiProduct: "Virtual disk", ScsiModelName: "VMWARE Virtual disk"}, true},
{"virtual scsi model only", smart.SmartctlOutput{ScsiModelName: "QEMU HARDDISK"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForSata{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ModelName: tt.model,
}
result := sm.isVirtualDevice(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceNvme(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
model string
expected bool
}{
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
{"qemu virtual", "QEMU NVMe Ctrl", true},
{"virtualbox virtual", "VBOX NVMe", true},
{"vmware virtual", "VMWARE NVMe", true},
{"virtual in model", "Virtual NVMe Device", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForNvme{
ModelName: tt.model,
}
result := sm.isVirtualDeviceNvme(data)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsVirtualDeviceScsi(t *testing.T) {
sm := &SmartManager{}
tests := []struct {
name string
vendor string
product string
model string
expected bool
}{
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := &smart.SmartInfoForScsi{
ScsiVendor: tt.vendor,
ScsiProduct: tt.product,
ScsiModelName: tt.model,
}
result := sm.isVirtualDeviceScsi(data)
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.expected, isVirtualDevice(&tt.data))
})
}
}
@@ -1012,7 +1033,7 @@ func TestFindAtaDeviceStatisticsValue(t *testing.T) {
tests := []struct {
name string
data smart.SmartInfoForSata
data smart.SmartctlOutput
ataDeviceStats smart.AtaDeviceStatistics
entryNumber uint8
entryName string
@@ -1040,7 +1061,7 @@ func TestFindAtaDeviceStatisticsValue(t *testing.T) {
},
{
name: "value unmarshaled from data",
data: smart.SmartInfoForSata{
data: smart.SmartctlOutput{
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
},
entryNumber: 5,
@@ -1087,7 +1108,7 @@ func TestFindAtaDeviceStatisticsValue(t *testing.T) {
},
{
name: "no statistics available",
data: smart.SmartInfoForSata{},
data: smart.SmartctlOutput{},
entryNumber: 5,
entryName: "Current Temperature",
minValue: 0,
@@ -1483,3 +1504,154 @@ func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
// Cache should be initialized even on error so we don't retry (Once already fired)
assert.NotNil(t, sm.darwinNvmeCapacity)
}
// A child test process acts as smartctl: the controller is inaccessible,
// while its namespace returns valid NVMe data.
func init() {
fixture := os.Getenv("BESZEL_SMART_TEST_FIXTURE")
if fixture == "" {
return
}
if os.Args[1] == "--scan" {
if scan := os.Getenv("BESZEL_SMART_TEST_SCAN"); scan != "" {
fmt.Print(scan)
} else {
fmt.Print(`{"devices":[{"name":"/dev/nvme0","type":"nvme","protocol":"NVMe"}]}`)
}
os.Exit(0)
}
if isNvmeControllerPath(os.Args[len(os.Args)-1]) || os.Getenv("BESZEL_SMART_TEST_NAMESPACE_FAIL") == "true" {
fmt.Print(`{"smartctl":{"exit_status":2,"messages":[{"string":"Permission denied","severity":"error"}]}}`)
os.Exit(2)
}
data, err := os.ReadFile(fixture)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Print(strings.ReplaceAll(string(data), "/dev/nvme0", os.Args[len(os.Args)-1]))
os.Exit(0)
}
func TestCollectSmartNvmeNamespaceFallback(t *testing.T) {
fixture, err := filepath.Abs(filepath.Join("test-data", "smart", "nvme0.json"))
require.NoError(t, err)
t.Setenv("BESZEL_SMART_TEST_FIXTURE", fixture)
executable, err := os.Executable()
require.NoError(t, err)
for _, cached := range []bool{false, true} {
t.Run(fmt.Sprintf("cached=%t", cached), func(t *testing.T) {
device := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
sm := &SmartManager{smartctlPath: executable, SmartDataMap: make(map[string]*smart.SmartData)}
if cached {
sm.SmartDataMap["2024031600129"] = &smart.SmartData{DiskName: device.Name, DiskType: device.Type}
}
require.NoError(t, sm.CollectSmart(device))
assert.Equal(t, "/dev/nvme0n1", device.Name)
assert.Equal(t, "/dev/nvme0n1", sm.SmartDataMap["2024031600129"].DiskName)
assert.Equal(t, "/dev/nvme0n1", sm.nvmeNamespacePaths["/dev/nvme0"])
assert.False(t, sm.isExcludedDevice("/dev/nvme0"))
})
}
}
func TestRefreshNvmeNamespaceFallbackSurvivesRescan(t *testing.T) {
fixture, err := filepath.Abs(filepath.Join("test-data", "smart", "nvme0.json"))
require.NoError(t, err)
t.Setenv("BESZEL_SMART_TEST_FIXTURE", fixture)
executable, err := os.Executable()
require.NoError(t, err)
sm := &SmartManager{smartctlPath: executable, SmartDataMap: make(map[string]*smart.SmartData)}
complete, err := sm.Refresh(false)
require.NoError(t, err)
require.True(t, complete)
require.Len(t, sm.SmartDevices, 1)
require.Equal(t, "/dev/nvme0n1", sm.SmartDevices[0].Name)
// Return a newer health reading to prove the next refresh queries the disk,
// rather than only preserving the previous cached data.
fixtureData, err := os.ReadFile(fixture)
require.NoError(t, err)
var updatedData smart.SmartctlOutput
require.NoError(t, json.Unmarshal(fixtureData, &updatedData))
require.NotNil(t, updatedData.NVMeSmartHealthInformationLog)
updatedData.NVMeSmartHealthInformationLog.Temperature++
fixtureData, err = json.Marshal(updatedData)
require.NoError(t, err)
updatedFixture := filepath.Join(t.TempDir(), "nvme.json")
require.NoError(t, os.WriteFile(updatedFixture, fixtureData, 0o600))
t.Setenv("BESZEL_SMART_TEST_FIXTURE", updatedFixture)
// Simulate the next refresh after the 30-minute scan cache has expired.
sm.lastScanTime = time.Now().Add(-31 * time.Minute)
complete, err = sm.Refresh(false)
require.NoError(t, err)
require.True(t, complete)
require.Len(t, sm.SmartDevices, 1)
assert.Equal(t, "/dev/nvme0n1", sm.SmartDevices[0].Name)
require.Contains(t, sm.SmartDataMap, "2024031600129")
assert.Equal(t, "/dev/nvme0n1", sm.SmartDataMap["2024031600129"].DiskName)
assert.Equal(t, uint8(updatedData.NVMeSmartHealthInformationLog.Temperature), sm.SmartDataMap["2024031600129"].Temperature)
assert.True(t, sm.SmartDevices[0].typeVerified)
assert.Equal(t, "nvme", sm.SmartDevices[0].parserType)
// Explicit controller configuration must keep resolving to the namespace.
t.Setenv("SMART_DEVICES", "/dev/nvme0:nvme")
require.NoError(t, sm.ScanDevices(true))
require.Len(t, sm.SmartDevices, 1)
assert.Equal(t, "/dev/nvme0n1", sm.SmartDevices[0].Name)
require.Contains(t, sm.SmartDataMap, "2024031600129")
// A scan that reports both aliases must not duplicate the drive.
t.Setenv("SMART_DEVICES", "")
require.NoError(t, os.Unsetenv("SMART_DEVICES"))
t.Setenv("BESZEL_SMART_TEST_SCAN", `{"devices":[{"name":"/dev/nvme0","type":"nvme"},{"name":"/dev/nvme0n1","type":"nvme"}]}`)
require.NoError(t, sm.ScanDevices(true))
require.Len(t, sm.SmartDevices, 1)
// Retaining a fallback must not retain a drive absent from a valid scan.
t.Setenv("BESZEL_SMART_TEST_SCAN", `{"devices":[{"name":"/dev/sda","type":"sat"}]}`)
require.NoError(t, sm.ScanDevices(true))
require.Len(t, sm.SmartDevices, 1)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Empty(t, sm.SmartDataMap)
assert.Empty(t, sm.nvmeNamespacePaths)
}
func TestCollectSmartNvmeNamespaceFailureKeepsController(t *testing.T) {
t.Setenv("BESZEL_SMART_TEST_FIXTURE", "unused")
t.Setenv("BESZEL_SMART_TEST_NAMESPACE_FAIL", "true")
executable, err := os.Executable()
require.NoError(t, err)
device := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
sm := &SmartManager{smartctlPath: executable, SmartDataMap: make(map[string]*smart.SmartData)}
require.Error(t, sm.CollectSmart(device))
assert.Equal(t, "/dev/nvme0", device.Name)
assert.Empty(t, sm.nvmeNamespacePaths)
assert.Empty(t, sm.SmartDataMap)
}
func TestNvmeNamespaceFallbackHonorsExclusions(t *testing.T) {
fixture, err := filepath.Abs(filepath.Join("test-data", "smart", "nvme0.json"))
require.NoError(t, err)
t.Setenv("BESZEL_SMART_TEST_FIXTURE", fixture)
executable, err := os.Executable()
require.NoError(t, err)
for _, excluded := range []string{"/dev/nvme0", "/dev/nvme0n1"} {
t.Run(excluded, func(t *testing.T) {
sm := &SmartManager{
smartctlPath: executable,
SmartDevices: []*DeviceInfo{{Name: "/dev/nvme0n1", Type: "nvme"}},
SmartDataMap: map[string]*smart.SmartData{"2024031600129": {DiskName: "/dev/nvme0n1", DiskType: "nvme"}},
nvmeNamespacePaths: map[string]string{"/dev/nvme0": "/dev/nvme0n1"},
excludedDevices: map[string]struct{}{excluded: {}},
}
require.ErrorIs(t, sm.ScanDevices(true), errNoValidSmartData)
assert.Empty(t, sm.SmartDevices)
assert.Empty(t, sm.SmartDataMap)
assert.Empty(t, sm.nvmeNamespacePaths)
})
}
}
+2
View File
@@ -391,6 +391,7 @@ func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.Zfs
Free: p.Free,
}
if st, ok := statusByPool[p.Name]; statusErr == nil && ok {
detail.Status = st.Status
if st.Scrub.State != "" && st.Scrub.State != "NONE" {
detail.Scrub = &zfsentity.Scrub{
State: st.Scrub.State,
@@ -409,6 +410,7 @@ func (b *poolBackend) collectDetail(previous *zfsentity.ZfsData) (*zfsentity.Zfs
}
} else {
if cached := previousByPool[p.Name]; cached != nil {
detail.Status = cached.Status
detail.Scrub = cached.Scrub
detail.Vdevs = cached.Vdevs
}
+3 -1
View File
@@ -386,13 +386,14 @@ func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
return []zfs.PoolStat{{Name: "tank"}}, nil
}
zm.backends[0].poolStatusesFn = func() ([]zfs.PoolStatus, error) {
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
return []zfs.PoolStatus{{Name: "tank", Status: "test advisory", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
}
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) {
return []zfs.Dataset{{Name: "tank/data"}}, nil
}
first := zm.GetDetail(false)
require.True(t, first.Complete)
assert.Equal(t, "test advisory", first.Pools[0].Status)
require.Len(t, first.Pools[0].Vdevs, 1)
require.Len(t, first.Pools[0].Datasets, 1)
@@ -400,6 +401,7 @@ func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
zm.backends[0].datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
partial := zm.GetDetail(true)
require.True(t, partial.Complete)
assert.Equal(t, "test advisory", partial.Pools[0].Status)
require.Len(t, partial.Pools[0].Vdevs, 1)
require.Len(t, partial.Pools[0].Datasets, 1)
+11 -1
View File
@@ -114,6 +114,13 @@ func newSystemdManager() (*systemdManager, error) {
return nil, err
}
// dbus may be present without systemd (e.g. runit, OpenRC), so verify systemd is on the bus (#2555)
if _, err := conn.GetManagerProperty("Version"); err != nil {
conn.Close()
slog.Debug("Systemd not available on dbus", "err", err)
return nil, nil
}
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
logsEnabled: systemdLogsEnabled(),
@@ -367,7 +374,10 @@ func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
// No --system: journald (SplitMode=uid) stores output from services running
// as a non-system User= in user-UID.journal, which --system excludes.
// --unit still restricts results to the system unit.
cmd := exec.CommandContext(ctx, "journalctl", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
output := limitedBuffer{limit: maxTotalLogSize}
cmd.Stdout = &output
stderr := limitedBuffer{limit: 1024}
+8 -5
View File
@@ -180,10 +180,13 @@ func TestIsSystemdAvailable(t *testing.T) {
// On systems without systemd, it should return false
result := isSystemdAvailable()
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
runSystemdExists := false
if _, err := os.Stat("/run/systemd/system"); err == nil {
runSystemdExists = true
// Check if the systemd directory or a dbus socket exists, or PID 1 is systemd
pathExists := false
for _, path := range []string{"/run/systemd/system", "/run/dbus/system_bus_socket", "/var/run/dbus/system_bus_socket"} {
if _, err := os.Stat(path); err == nil {
pathExists = true
break
}
}
pid1IsSystemd := false
@@ -191,7 +194,7 @@ func TestIsSystemdAvailable(t *testing.T) {
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
}
expected := runSystemdExists || pid1IsSystemd
expected := pathExists || pid1IsSystemd
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
@@ -0,0 +1,3 @@
libgbm-26.2.4_1 update x86_64 https://void.sakamoto.pl/current 18488 6762
libmariadbclient-12.3.2_2 update x86_64 https://void.sakamoto.pl/current 27749752 7203074
libpipewire-1.6.9_1 update x86_64 https://void.sakamoto.pl/current 4895536 1155261
@@ -0,0 +1,7 @@
acl-2.4.0_1 update x86_64 https://repo-default.voidlinux.org/current 43080 20653
attr-2.6.0_1 update x86_64 https://repo-default.voidlinux.org/current 27348 9495
ca-certificates-20250419+3.127_1 update x86_64 https://repo-default.voidlinux.org/current 273335 164765
libarchive-3.8.9_1 update x86_64 https://repo-default.voidlinux.org/current 817232 380316
libcrypto3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 6443040 2193502
libssl3-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 1065048 444864
openssl-3.6.5_1 update x86_64 https://repo-default.voidlinux.org/current 2270125 635762
@@ -0,0 +1,29 @@
ii acl-2.4.0_1 Access Control List filesystem support
ii attr-2.6.0_1 Extended attribute support library for ACL support
ii base-files-0.146_1 Void Linux base system files
ii bzip2-1.0.8_2 Freely available, patent free, high-quality data compressor
ii ca-certificates-20250419+3.127_1 Common CA certificates for SSL/TLS from Mozilla
ii coreutils-9.4_3 GNU core utilities
ii dash-0.5.12_3 POSIX-compliant Unix shell, much smaller than GNU bash
ii gawk-5.3.2_1 GNU awk utility
ii glibc-2.41_1 GNU C library
ii gmp-6.3.0_1 Library for arbitrary precision arithmetic
ii grep-3.12_1 GNU grep utility
ii libarchive-3.8.9_1 Library to read/write several different streaming archive formats
ii libcap-2.78_1 POSIX.1e capabilities suite
ii libcrypto3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - crypto library
ii liblz4-1.10.0_1 LZ4 compression library
ii liblzma-5.8.3_1 XZ-format compression library
ii libpcre2-10.47_1 Perl Compatible Regular Expressions (2nd version) - shared libraries
ii libreadline8-8.2.010_1 GNU Readline Library
ii libssl3-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security - SSL/TLS library
ii libxbps-0.60.7_1 XBPS package system utilities - runtime library
ii libxcrypt-compat-4.4.38_1 Modern library for one-way hashing of passwords - legacy compatibility
ii libzstd-1.5.7_1 Fast real-time compression algorithm
ii ncurses-libs-6.6_1 System V Release 4.0 curses emulation library -- shared libraries
ii openssl-3.6.5_1 Toolkit for Secure Sockets Layer and Transport Layer Security
ii run-parts-5.23.2_1 Run scripts or programs in a directory
ii sed-4.10_1 GNU stream editor
ii xbps-0.60.7_1 XBPS package system utilities
ii xbps-triggers-0.131_1 XBPS triggers for Void Linux
ii zlib-1.3.2_1 Compression/decompression Library
+18
View File
@@ -27,3 +27,21 @@ config:
sdb FAULTED 1 2 3
errors: 1 data errors, use '-v' for a list
pool: pve1storage
state: ONLINE
status: One or more devices has experienced an unrecoverable error. An
attempt was made to correct the error. Applications are unaffected.
action: Determine if the device needs to be replaced, and clear the errors
using 'zpool clear' or replace the device with 'zpool replace'.
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-9P
scan: scrub repaired 208K in 15:18:53 with 0 errors on Thu Oct 1 15:40:36 2026
config:
NAME STATE READ WRITE CKSUM
pve1storage ONLINE 0 0 0
raidz2-0 ONLINE 0 0 0
sda ONLINE 0 0 0
sdb ONLINE 0 0 1
errors: No known data errors
+54 -2
View File
@@ -68,7 +68,8 @@ type Dataset struct {
}
// PoolStats returns capacity and health for all pools on the system using
// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead.
// `zpool list`, with capacity taken from each pool's root dataset where it can
// be read. Frequent health and I/O sampling uses PoolKernelStats instead.
func PoolStats() ([]PoolStat, error) {
if err := checkZfsDevice(); err != nil {
return nil, err
@@ -81,7 +82,58 @@ func PoolStats() ([]PoolStat, error) {
}
return nil, fmt.Errorf("zpool list: %w", err)
}
return parseZpoolListOutput(out)
pools, err := parseZpoolListOutput(out)
if err != nil || len(pools) == 0 {
return pools, err
}
applyRootDatasetCapacity(pools)
return pools, nil
}
// applyRootDatasetCapacity replaces the raw `zpool list` figures, which include
// parity on raidz and dRAID vdevs, with each pool's root dataset used and avail.
// As with Btrfs, the effective capacity is used+avail, so Size-Alloc is what
// applications can still write and the usage ratio matches df. A pool whose
// root dataset cannot be read keeps its raw figures and is flagged as Raw.
func applyRootDatasetCapacity(pools []PoolStat) {
var roots map[string]Dataset
if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,refquota"); err == nil {
roots = parseRootDatasetOutput(out)
}
for i := range pools {
root, ok := roots[pools[i].Name]
if !ok {
pools[i].Raw = true
continue
}
pools[i].Size, pools[i].Alloc, pools[i].Free = root.Used+root.Avail, root.Used, root.Avail
}
}
// parseRootDatasetOutput parses `zfs list -Hp -d 0 -o name,used,avail,refquota`
// output into root datasets keyed by pool name. Roots with a refquota are left
// out: the quota caps avail by the root's own data rather than the pool's free
// space, which would understate capacity when children hold most of the data.
// Malformed lines are skipped, so their pools fall back to raw figures.
func parseRootDatasetOutput(out []byte) map[string]Dataset {
roots := make(map[string]Dataset)
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
fields := strings.Split(strings.TrimSpace(scanner.Text()), "\t")
if len(fields) != 4 || (fields[3] != "0" && fields[3] != "none") {
continue
}
used, err := strconv.ParseUint(fields[1], 10, 64)
if err != nil {
continue
}
avail, err := strconv.ParseUint(fields[2], 10, 64)
if err != nil {
continue
}
roots[fields[0]] = Dataset{Name: fields[0], Used: used, Avail: avail}
}
return roots
}
// Datasets returns all datasets on the system with usage and mountpoint
+48 -5
View File
@@ -3,6 +3,7 @@
package zfs
import (
"errors"
"os"
"path/filepath"
"testing"
@@ -260,15 +261,57 @@ func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) {
oldCommandOutput := commandOutput
called := false
commandOutput = func(name string, args ...string) ([]byte, error) {
called = true
assert.Equal(t, "zpool", name)
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
switch name {
case "zpool":
called = true
assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args)
// raidz: the raw figures include parity
return []byte("tank\t150\t60\t90\tONLINE\n"), nil
case "zfs":
assert.Equal(t, []string{"list", "-Hp", "-d", "0", "-o", "name,used,avail,refquota"}, args)
return []byte("tank\t40\t60\t0\n"), nil
}
t.Fatalf("unexpected %s call with %v", name, args)
return nil, nil
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.True(t, called)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 40, Free: 60, Health: "ONLINE"}}, pools)
}
func TestPoolStatsKeepsRawCapacityWithoutRootDataset(t *testing.T) {
devFile := filepath.Join(t.TempDir(), "zfs")
require.NoError(t, os.WriteFile(devFile, nil, 0o644))
oldDevZfsPath := devZfsPath
devZfsPath = devFile
t.Cleanup(func() { devZfsPath = oldDevZfsPath })
for _, tc := range []struct {
name string
zfsOut []byte
zfsErr error
}{
{"zfs list fails", nil, errors.New("zfs list failed")},
{"root dataset missing", []byte("other\t1\t1\t0\n"), nil},
// a refquota caps the root's avail below the pool's free space
{"root dataset has refquota", []byte("tank\t40\t10\t20\n"), nil},
} {
t.Run(tc.name, func(t *testing.T) {
oldCommandOutput := commandOutput
commandOutput = func(name string, args ...string) ([]byte, error) {
if name == "zpool" {
return []byte("tank\t150\t60\t90\tONLINE\n"), nil
}
return tc.zfsOut, tc.zfsErr
}
t.Cleanup(func() { commandOutput = oldCommandOutput })
pools, err := PoolStats()
require.NoError(t, err)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 150, Alloc: 60, Free: 90, Health: "ONLINE", Raw: true}}, pools)
})
}
}
+5 -1
View File
@@ -3,6 +3,7 @@
package zfs
import (
"slices"
"testing"
"github.com/stretchr/testify/assert"
@@ -16,6 +17,9 @@ func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
case "zpool":
return []byte("tank\t100\t50\t50\tONLINE\n"), nil
case "zfs":
if slices.Contains(args, "-d") {
return []byte("tank\t40\t60\tnone\n"), nil
}
return []byte("tank\t50\t50\t/tank\n"), nil
default:
t.Fatalf("unexpected command %s", name)
@@ -26,7 +30,7 @@ func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) {
pools, err := PoolStats()
require.NoError(t, err)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools)
assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 40, Free: 60, Health: "ONLINE"}}, pools)
datasets, err := Datasets()
require.NoError(t, err)
assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets)
+20 -4
View File
@@ -11,10 +11,11 @@ import (
// PoolStatus holds parsed `zpool status` information for one pool.
type PoolStatus struct {
Name string
State string // ONLINE, DEGRADED, FAULTED, ...
Scrub ScrubStatus
Vdevs []VdevStatus
Name string
State string // ONLINE, DEGRADED, FAULTED, ...
Status string // status: advisory message, when the pool carries one
Scrub ScrubStatus
Vdevs []VdevStatus
}
// ScrubStatus holds the scrub (or resilver) status parsed from the scan line.
@@ -54,6 +55,7 @@ func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
var pools []PoolStatus
var current *PoolStatus
inConfig := false
inStatus := false // next indented line continues the status: message
scanContinuation := false // next non-blank line continues the scan line (progress)
scanner := bufio.NewScanner(bytes.NewReader(out))
@@ -66,17 +68,31 @@ func parseZpoolStatusOutput(out []byte) ([]PoolStatus, error) {
pools = append(pools, PoolStatus{Name: strings.TrimSpace(strings.TrimPrefix(trimmed, "pool:"))})
current = &pools[len(pools)-1]
inConfig = false
inStatus = false
scanContinuation = false
case current == nil:
continue
case strings.HasPrefix(trimmed, "state:"):
current.State = strings.TrimSpace(strings.TrimPrefix(trimmed, "state:"))
case strings.HasPrefix(trimmed, "status:"):
current.Status = strings.Join(strings.Fields(strings.TrimPrefix(trimmed, "status:")), " ")
inStatus = true
case strings.HasPrefix(trimmed, "action:"), strings.HasPrefix(trimmed, "see:"):
// action:/see: follow the status message; see: is itself indented.
inStatus = false
case strings.HasPrefix(trimmed, "scan:"):
current.Scrub = parseScanLine(trimmed)
// zpool status prints the progress percentage on the line after scan.
scanContinuation = true
inStatus = false
case trimmed == "config:":
inConfig = true
inStatus = false
case inStatus && trimmed != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")):
// The status message wraps onto indented continuation lines.
current.Status = strings.TrimSpace(current.Status + " " + strings.Join(strings.Fields(trimmed), " "))
case inStatus:
inStatus = false
case scanContinuation:
// The line after scan: may be an indented progress continuation.
if m := progressRe.FindStringSubmatch(trimmed); m != nil {
+28 -1
View File
@@ -67,17 +67,32 @@ func TestParseZfsListOutput(t *testing.T) {
assert.Equal(t, uint64(11999000000000), datasets[0].Avail)
}
func TestParseRootDatasetOutput(t *testing.T) {
roots := parseRootDatasetOutput([]byte(
"tank\t40\t60\t0\n" +
"backup\t5\t15\tnone\n" +
"quota\t40\t10\t20\n" +
"broken\tnotanumber\t60\t0\n" +
"short\t40\t60\n",
))
assert.Equal(t, map[string]Dataset{
"tank": {Name: "tank", Used: 40, Avail: 60},
"backup": {Name: "backup", Used: 5, Avail: 15},
}, roots)
}
func TestParseZpoolStatusOutput(t *testing.T) {
data, err := os.ReadFile(fixturePath("zpool_status.txt"))
require.NoError(t, err)
pools, err := parseZpoolStatusOutput(data)
require.NoError(t, err)
require.Len(t, pools, 2)
require.Len(t, pools, 3)
tank := pools[0]
assert.Equal(t, "tank", tank.Name)
assert.Equal(t, "ONLINE", tank.State)
assert.Empty(t, tank.Status)
assert.Equal(t, "FINISHED", tank.Scrub.State)
assert.Equal(t, "", tank.Scrub.Progress)
assert.Equal(t, uint64(0), tank.Scrub.Errors)
@@ -90,6 +105,7 @@ func TestParseZpoolStatusOutput(t *testing.T) {
rpool := pools[1]
assert.Equal(t, "rpool", rpool.Name)
assert.Equal(t, "DEGRADED", rpool.State)
assert.Equal(t, "One or more devices could not be used because the label is missing or invalid. Sufficient replicas exist for the pool to continue functioning in a degraded state.", rpool.Status)
assert.Equal(t, "SCANNING", rpool.Scrub.State)
assert.Equal(t, "10.00%", rpool.Scrub.Progress)
require.Len(t, rpool.Vdevs, 3)
@@ -97,6 +113,17 @@ func TestParseZpoolStatusOutput(t *testing.T) {
assert.Equal(t, uint64(1), rpool.Vdevs[2].ReadErrs)
assert.Equal(t, uint64(2), rpool.Vdevs[2].WriteErrs)
assert.Equal(t, uint64(3), rpool.Vdevs[2].ChecksumErrs)
// state: ONLINE with a status: message (ZFS-8000-9P) - issue #2490.
// The status text stops at action:/see: and the pool stays ONLINE.
pve := pools[2]
assert.Equal(t, "pve1storage", pve.Name)
assert.Equal(t, "ONLINE", pve.State)
assert.Equal(t, "One or more devices has experienced an unrecoverable error. An attempt was made to correct the error. Applications are unaffected.", pve.Status)
assert.Equal(t, "FINISHED", pve.Scrub.State)
require.Len(t, pve.Vdevs, 3)
assert.Equal(t, "raidz2-0", pve.Vdevs[0].Name)
assert.Equal(t, uint64(1), pve.Vdevs[2].ChecksumErrs)
}
func TestParseScanLine(t *testing.T) {
+1 -1
View File
@@ -67,7 +67,7 @@ type SystemAlertGPUData struct {
}
type SystemAlertZfsPool struct {
Raw bool `json:"raw,omitempty"`
Raw bool `json:"raw,omitzero"`
Total float64 `json:"d"`
Used float64 `json:"du"`
}
+1 -1
View File
@@ -47,7 +47,7 @@ type AgentResponse struct {
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
+6 -6
View File
@@ -123,9 +123,9 @@ type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only. Populated from /proc/stat on Docker; from cgroup cpu.stat on Podman.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
SystemUsage uint64 `json:"system_cpu_usage,omitzero"`
// Number of online CPUs. Linux only. Used by Podman for time-based CPU calculation.
OnlineCPUs uint32 `json:"online_cpus,omitempty"`
OnlineCPUs uint32 `json:"online_cpus,omitzero"`
}
type CPUUsage struct {
@@ -137,16 +137,16 @@ type CPUUsage struct {
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
Usage uint64 `json:"usage,omitzero"`
// all the stats exported via memory.stat.
Stats MemoryStatsStats `json:"stats"`
// private working set (Windows only)
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
PrivateWorkingSet uint64 `json:"privateworkingset,omitzero"`
}
type MemoryStatsStats struct {
Cache uint64 `json:"cache,omitempty"`
InactiveFile uint64 `json:"inactive_file,omitempty"`
Cache uint64 `json:"cache,omitzero"`
InactiveFile uint64 `json:"inactive_file,omitzero"`
}
type NetworkStats struct {
+95 -371
View File
@@ -6,16 +6,44 @@ import (
"strings"
)
// Common types
type VersionInfo [2]int
// SmartctlOutput holds the fields we read from `smartctl -a --json` for any
// protocol. Sections that do not apply to a device are left nil or empty.
//
// Counters use wide integer types on purpose: a value that overflows its Go type
// makes json.Unmarshal fail and drops the whole device (see #2484, #2582).
type SmartctlOutput struct {
Smartctl struct {
ExitStatus int `json:"exit_status"`
} `json:"smartctl"`
Device DeviceInfo `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
FirmwareVersion string `json:"firmware_version"`
UserCapacity UserCapacity `json:"user_capacity"`
SmartStatus struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
Temperature struct {
Current int64 `json:"current"`
} `json:"temperature"`
type SmartctlInfo struct {
Version VersionInfo `json:"version"`
SvnRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
// ATA
AtaSmartAttributes *AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics json.RawMessage `json:"ata_device_statistics"` // decoded lazily, only when needed
// NVMe
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
// SCSI (scsi_vendor/scsi_product are also reported for SATA disks behind SAT)
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
ScsiModelName string `json:"scsi_model_name"`
ScsiRevision string `json:"scsi_revision"`
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
ScsiErrorCounterLog *ScsiErrorCounterLog `json:"scsi_error_counter_log"`
}
type DeviceInfo struct {
@@ -30,109 +58,20 @@ type UserCapacity struct {
Bytes uint64 `json:"bytes"`
}
// type LocalTime struct {
// TimeT int64 `json:"time_t"`
// Asctime string `json:"asctime"`
// }
// type WwnInfo struct {
// Naa int `json:"naa"`
// Oui int `json:"oui"`
// ID int `json:"id"`
// }
// type FormFactorInfo struct {
// AtaValue int `json:"ata_value"`
// Name string `json:"name"`
// }
// type TrimInfo struct {
// Supported bool `json:"supported"`
// }
// type AtaVersionInfo struct {
// String string `json:"string"`
// MajorValue int `json:"major_value"`
// MinorValue int `json:"minor_value"`
// }
// type VersionStringInfo struct {
// String string `json:"string"`
// Value int `json:"value"`
// }
// type SpeedInfo struct {
// SataValue int `json:"sata_value"`
// String string `json:"string"`
// UnitsPerSecond int `json:"units_per_second"`
// BitsPerUnit int `json:"bits_per_unit"`
// }
// type InterfaceSpeedInfo struct {
// Max SpeedInfo `json:"max"`
// Current SpeedInfo `json:"current"`
// }
type SmartStatusInfo struct {
Passed bool `json:"passed"`
}
type StatusInfo struct {
Value int `json:"value"`
String string `json:"string"`
Passed bool `json:"passed"`
}
type PollingMinutes struct {
Short int `json:"short"`
Extended int `json:"extended"`
}
type CapabilitiesInfo struct {
Values []int `json:"values"`
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
SelfTestsSupported bool `json:"self_tests_supported"`
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
ErrorLoggingSupported bool `json:"error_logging_supported"`
GpLoggingSupported bool `json:"gp_logging_supported"`
}
// type AtaSmartData struct {
// OfflineDataCollection OfflineDataCollectionInfo `json:"offline_data_collection"`
// SelfTest SelfTestInfo `json:"self_test"`
// Capabilities CapabilitiesInfo `json:"capabilities"`
// }
// type OfflineDataCollectionInfo struct {
// Status StatusInfo `json:"status"`
// CompletionSeconds int `json:"completion_seconds"`
// }
// type SelfTestInfo struct {
// Status StatusInfo `json:"status"`
// PollingMinutes PollingMinutes `json:"polling_minutes"`
// }
// type AtaSctCapabilities struct {
// Value int `json:"value"`
// ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
// FeatureControlSupported bool `json:"feature_control_supported"`
// DataTableSupported bool `json:"data_table_supported"`
// }
type SummaryInfo struct {
Revision int `json:"revision"`
Count int `json:"count"`
}
type AtaSmartAttributes struct {
Table []AtaSmartAttribute `json:"table"`
}
type AtaSmartAttribute struct {
ID uint16 `json:"id"`
Name string `json:"name"`
Value uint16 `json:"value"`
Worst uint16 `json:"worst"`
Thresh uint16 `json:"thresh"`
WhenFailed string `json:"when_failed"`
Raw RawValue `json:"raw"`
}
type AtaDeviceStatistics struct {
Pages []AtaDeviceStatisticsPage `json:"pages"`
}
@@ -147,27 +86,50 @@ type AtaDeviceStatisticsEntry struct {
Value *int64 `json:"value,omitempty"`
}
type AtaSmartAttribute struct {
ID uint16 `json:"id"`
Name string `json:"name"`
Value uint16 `json:"value"`
Worst uint16 `json:"worst"`
Thresh uint16 `json:"thresh"`
WhenFailed string `json:"when_failed"`
// Flags AttributeFlags `json:"flags"`
Raw RawValue `json:"raw"`
type NVMeSmartHealthInformationLog struct {
CriticalWarning uint64 `json:"critical_warning"`
Temperature int64 `json:"temperature"`
AvailableSpare uint64 `json:"available_spare"`
AvailableSpareThreshold uint64 `json:"available_spare_threshold"`
PercentageUsed uint64 `json:"percentage_used"`
DataUnitsRead uint64 `json:"data_units_read"`
DataUnitsWritten uint64 `json:"data_units_written"`
HostReads uint64 `json:"host_reads"`
HostWrites uint64 `json:"host_writes"`
ControllerBusyTime uint64 `json:"controller_busy_time"`
PowerCycles uint64 `json:"power_cycles"`
PowerOnHours uint64 `json:"power_on_hours"`
UnsafeShutdowns uint64 `json:"unsafe_shutdowns"`
MediaErrors uint64 `json:"media_errors"`
NumErrLogEntries uint64 `json:"num_err_log_entries"`
WarningTempTime uint64 `json:"warning_temp_time"`
CriticalCompTime uint64 `json:"critical_comp_time"`
}
// type AttributeFlags struct {
// Value int `json:"value"`
// String string `json:"string"`
// Prefailure bool `json:"prefailure"`
// UpdatedOnline bool `json:"updated_online"`
// Performance bool `json:"performance"`
// ErrorRate bool `json:"error_rate"`
// EventCount bool `json:"event_count"`
// AutoKeep bool `json:"auto_keep"`
// }
type ScsiErrorCounter struct {
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
GigabytesProcessed string `json:"gigabytes_processed"`
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
}
type ScsiErrorCounterLog struct {
Read ScsiErrorCounter `json:"read"`
Write ScsiErrorCounter `json:"write"`
Verify ScsiErrorCounter `json:"verify"`
}
type ScsiStartStopCycleCounter struct {
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
}
type PowerOnTimeScsi struct {
Hours uint64 `json:"hours"`
Minutes uint64 `json:"minutes"`
}
type RawValue struct {
Value SmartRawValue `json:"value"`
@@ -280,254 +242,16 @@ func ParseSmartRawValueString(value string) (uint64, bool) {
return 0, false
}
// type PowerOnTimeInfo struct {
// Hours uint32 `json:"hours"`
// }
type TemperatureInfo struct {
Current uint8 `json:"current"`
}
type TemperatureInfoScsi struct {
Current uint8 `json:"current"`
DriveTrip uint8 `json:"drive_trip"`
}
// type SelectiveSelfTestTable struct {
// LbaMin int `json:"lba_min"`
// LbaMax int `json:"lba_max"`
// Status StatusInfo `json:"status"`
// }
// type SelectiveSelfTestFlags struct {
// Value int `json:"value"`
// RemainderScanEnabled bool `json:"remainder_scan_enabled"`
// }
// type AtaSmartSelectiveSelfTestLog struct {
// Revision int `json:"revision"`
// Table []SelectiveSelfTestTable `json:"table"`
// Flags SelectiveSelfTestFlags `json:"flags"`
// PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
// }
// BaseSmartInfo contains common fields shared between SATA and NVMe drives
// type BaseSmartInfo struct {
// Device DeviceInfo `json:"device"`
// ModelName string `json:"model_name"`
// SerialNumber string `json:"serial_number"`
// FirmwareVersion string `json:"firmware_version"`
// UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"`
// }
type SmartctlInfoLegacy struct {
Version VersionInfo `json:"version"`
SvnRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
}
type SmartInfoForSata struct {
// JSONFormatVersion VersionInfo `json:"json_format_version"`
Smartctl SmartctlInfoLegacy `json:"smartctl"`
Device DeviceInfo `json:"device"`
// ModelFamily string `json:"model_family"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
// Wwn WwnInfo `json:"wwn"`
FirmwareVersion string `json:"firmware_version"`
UserCapacity UserCapacity `json:"user_capacity"`
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
// LogicalBlockSize int `json:"logical_block_size"`
// PhysicalBlockSize int `json:"physical_block_size"`
// RotationRate int `json:"rotation_rate"`
// FormFactor FormFactorInfo `json:"form_factor"`
// Trim TrimInfo `json:"trim"`
// InSmartctlDatabase bool `json:"in_smartctl_database"`
// AtaVersion AtaVersionInfo `json:"ata_version"`
// SataVersion VersionStringInfo `json:"sata_version"`
// InterfaceSpeed InterfaceSpeedInfo `json:"interface_speed"`
// LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfo `json:"smart_status"`
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics json.RawMessage `json:"ata_device_statistics"`
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
// PowerCycleCount uint16 `json:"power_cycle_count"`
Temperature TemperatureInfo `json:"temperature"`
// AtaSmartErrorLog AtaSmartErrorLog `json:"ata_smart_error_log"`
// AtaSmartSelfTestLog AtaSmartSelfTestLog `json:"ata_smart_self_test_log"`
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"`
}
type ScsiErrorCounter struct {
ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"`
ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"`
ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"`
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
GigabytesProcessed string `json:"gigabytes_processed"`
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
}
type ScsiErrorCounterLog struct {
Read ScsiErrorCounter `json:"read"`
Write ScsiErrorCounter `json:"write"`
Verify ScsiErrorCounter `json:"verify"`
}
type ScsiStartStopCycleCounter struct {
YearOfManufacture string `json:"year_of_manufacture"`
WeekOfManufacture string `json:"week_of_manufacture"`
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
}
type PowerOnTimeScsi struct {
Hours uint64 `json:"hours"`
Minutes uint64 `json:"minutes"`
}
type SmartInfoForScsi struct {
Smartctl SmartctlInfoLegacy `json:"smartctl"`
Device DeviceInfo `json:"device"`
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
ScsiModelName string `json:"scsi_model_name"`
ScsiRevision string `json:"scsi_revision"`
ScsiVersion string `json:"scsi_version"`
SerialNumber string `json:"serial_number"`
UserCapacity UserCapacity `json:"user_capacity"`
Temperature TemperatureInfoScsi `json:"temperature"`
SmartStatus SmartStatusInfo `json:"smart_status"`
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"`
}
// type AtaSmartErrorLog struct {
// Summary SummaryInfo `json:"summary"`
// }
// type AtaSmartSelfTestLog struct {
// Standard SummaryInfo `json:"standard"`
// }
type SmartctlInfoNvme struct {
Version VersionInfo `json:"version"`
SVNRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
}
// type NVMePCIVendor struct {
// ID int `json:"id"`
// SubsystemID int `json:"subsystem_id"`
// }
// type SizeCapacityInfo struct {
// Blocks uint64 `json:"blocks"`
// Bytes uint64 `json:"bytes"`
// }
// type EUI64Info struct {
// OUI int `json:"oui"`
// ExtID int `json:"ext_id"`
// }
// type NVMeNamespace struct {
// ID uint32 `json:"id"`
// Size SizeCapacityInfo `json:"size"`
// Capacity SizeCapacityInfo `json:"capacity"`
// Utilization SizeCapacityInfo `json:"utilization"`
// FormattedLBASize uint32 `json:"formatted_lba_size"`
// EUI64 EUI64Info `json:"eui64"`
// }
type SmartStatusInfoNvme struct {
Passed bool `json:"passed"`
NVMe SmartStatusNVMe `json:"nvme"`
}
type SmartStatusNVMe struct {
Value int `json:"value"`
}
type NVMeSmartHealthInformationLog struct {
CriticalWarning uint `json:"critical_warning"`
Temperature uint8 `json:"temperature"`
AvailableSpare uint `json:"available_spare"`
AvailableSpareThreshold uint `json:"available_spare_threshold"`
PercentageUsed uint8 `json:"percentage_used"`
DataUnitsRead uint64 `json:"data_units_read"`
DataUnitsWritten uint64 `json:"data_units_written"`
HostReads uint `json:"host_reads"`
HostWrites uint `json:"host_writes"`
ControllerBusyTime uint `json:"controller_busy_time"`
PowerCycles uint `json:"power_cycles"`
PowerOnHours uint32 `json:"power_on_hours"`
UnsafeShutdowns uint16 `json:"unsafe_shutdowns"`
MediaErrors uint `json:"media_errors"`
NumErrLogEntries uint `json:"num_err_log_entries"`
WarningTempTime uint `json:"warning_temp_time"`
CriticalCompTime uint `json:"critical_comp_time"`
TemperatureSensors []uint8 `json:"temperature_sensors"`
}
type SmartInfoForNvme struct {
// JSONFormatVersion VersionInfo `json:"json_format_version"`
Smartctl SmartctlInfoNvme `json:"smartctl"`
Device DeviceInfo `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"`
// NVMeNumberOfNamespaces uint8 `json:"nvme_number_of_namespaces"`
// NVMeNamespaces []NVMeNamespace `json:"nvme_namespaces"`
UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
}
type TemperatureInfoNvme struct {
Current int `json:"current"`
}
type PowerOnTimeInfoNvme struct {
Hours int `json:"hours"`
}
type SmartData struct {
// ModelFamily string `json:"mf,omitempty" cbor:"0,keyasint,omitempty"`
ModelName string `json:"mn,omitempty" cbor:"1,keyasint,omitempty"`
SerialNumber string `json:"sn,omitempty" cbor:"2,keyasint,omitempty"`
FirmwareVersion string `json:"fv,omitempty" cbor:"3,keyasint,omitempty"`
Capacity uint64 `json:"c,omitempty" cbor:"4,keyasint,omitempty"`
Capacity uint64 `json:"c,omitzero" cbor:"4,keyasint,omitempty"`
SmartStatus string `json:"s,omitempty" cbor:"5,keyasint,omitempty"`
DiskName string `json:"dn,omitempty" cbor:"6,keyasint,omitempty"`
DiskType string `json:"dt,omitempty" cbor:"7,keyasint,omitempty"`
Temperature uint8 `json:"t,omitempty" cbor:"8,keyasint,omitempty"`
Temperature uint8 `json:"t,omitzero" cbor:"8,keyasint,omitempty"`
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
}
@@ -539,11 +263,11 @@ type SmartDataResponse struct {
}
type SmartAttribute struct {
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
ID uint16 `json:"id,omitzero" cbor:"0,keyasint,omitempty"`
Name string `json:"n" cbor:"1,keyasint"`
Value uint16 `json:"v,omitempty" cbor:"2,keyasint,omitempty"`
Worst uint16 `json:"w,omitempty" cbor:"3,keyasint,omitempty"`
Threshold uint16 `json:"t,omitempty" cbor:"4,keyasint,omitempty"`
Value uint16 `json:"v,omitzero" cbor:"2,keyasint,omitempty"`
Worst uint16 `json:"w,omitzero" cbor:"3,keyasint,omitempty"`
Threshold uint16 `json:"t,omitzero" cbor:"4,keyasint,omitempty"`
RawValue uint64 `json:"rv" cbor:"5,keyasint"`
RawString string `json:"rs,omitempty" cbor:"6,keyasint,omitempty"`
WhenFailed string `json:"wf,omitempty" cbor:"7,keyasint,omitempty"`
+3 -3
View File
@@ -5,7 +5,7 @@ type PackageUpdate struct {
Name string `json:"name" cbor:"0,keyasint"`
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
Available string `json:"available" cbor:"2,keyasint"`
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
Security bool `json:"security,omitzero" cbor:"3,keyasint,omitempty"`
}
// PackageUpdates is the detail payload returned by the agent for the
@@ -13,8 +13,8 @@ type PackageUpdate struct {
type PackageUpdates struct {
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
CheckedAt int64 `json:"checkedAt,omitzero" cbor:"1,keyasint,omitempty"`
// SecurityKnown is true if the package manager flags security updates per package.
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
SecurityKnown bool `json:"securityKnown,omitzero" cbor:"2,keyasint,omitempty"`
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
}
+28 -28
View File
@@ -21,26 +21,26 @@ type WiFi struct {
type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
MaxCpu float64 `json:"cpum,omitzero" cbor:"-"`
Mem float64 `json:"m" cbor:"2,keyasint"`
MaxMem float64 `json:"mm,omitempty" cbor:"-"`
MaxMem float64 `json:"mm,omitzero" cbor:"-"`
MemUsed float64 `json:"mu" cbor:"3,keyasint"`
MemPct float64 `json:"mp" cbor:"4,keyasint"`
MemBuffCache float64 `json:"mb" cbor:"5,keyasint"`
MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"`
SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"`
MemZfsArc float64 `json:"mz,omitzero" cbor:"6,keyasint,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitzero" cbor:"7,keyasint,omitempty"`
SwapUsed float64 `json:"su,omitzero" cbor:"8,keyasint,omitempty"`
DiskTotal float64 `json:"d" cbor:"9,keyasint"`
DiskUsed float64 `json:"du" cbor:"10,keyasint"`
DiskPct float64 `json:"dp" cbor:"11,keyasint"`
DiskReadPs float64 `json:"dr,omitzero" cbor:"12,keyasint,omitzero"`
DiskWritePs float64 `json:"dw,omitzero" cbor:"13,keyasint,omitzero"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"-"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"-"`
MaxDiskReadPs float64 `json:"drm,omitzero" cbor:"-"`
MaxDiskWritePs float64 `json:"dwm,omitzero" cbor:"-"`
NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
MaxNetworkSent float64 `json:"nsm,omitzero" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitzero" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
@@ -70,9 +70,9 @@ type Stats struct {
// ZfsPool holds per-pool ZFS metrics for a single collection interval.
type ZfsPool struct {
DisplayName string `json:"n,omitempty" cbor:"8,keyasint,omitempty"`
HideUsage bool `json:"hu,omitempty" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists
HideIO bool `json:"hi,omitempty" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists
Raw bool `json:"raw,omitempty" cbor:"5,keyasint,omitempty"`
HideUsage bool `json:"hu,omitzero" cbor:"6,keyasint,omitempty"` // equivalent filesystem usage chart exists
HideIO bool `json:"hi,omitzero" cbor:"7,keyasint,omitempty"` // equivalent filesystem I/O chart exists
Raw bool `json:"raw,omitzero" cbor:"5,keyasint,omitempty"`
Total float64 `json:"d" cbor:"0,keyasint"` // total capacity in GiB
Used float64 `json:"du" cbor:"1,keyasint"` // allocated in GiB
ReadBytes uint64 `json:"rb,omitzero" cbor:"2,keyasint,omitzero"` // read throughput in bytes/s
@@ -109,13 +109,13 @@ func (b Battery) MarshalJSON() ([]byte, error) {
type GPUData struct {
Name string `json:"n" cbor:"0,keyasint"`
Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
MemoryUsed float64 `json:"mu,omitzero" cbor:"1,keyasint,omitempty,omitzero"`
MemoryTotal float64 `json:"mt,omitzero" cbor:"2,keyasint,omitempty,omitzero"`
Usage float64 `json:"u" cbor:"3,keyasint,omitempty"`
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
Power float64 `json:"p,omitzero" cbor:"4,keyasint,omitempty"`
Count float64 `json:"-"`
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
PowerPkg float64 `json:"pp,omitempty" cbor:"6,keyasint,omitempty"`
PowerPkg float64 `json:"pp,omitzero" cbor:"6,keyasint,omitempty"`
}
type FsStats struct {
@@ -128,13 +128,13 @@ type FsStats struct {
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
MaxDiskReadPS float64 `json:"rm,omitzero" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitzero" cbor:"-"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
MaxDiskReadBytes uint64 `json:"rbm,omitzero" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitzero" cbor:"-"`
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"8,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
}
@@ -169,7 +169,7 @@ type Info struct {
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
Threads int `json:"t,omitzero" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
@@ -177,24 +177,24 @@ type Info struct {
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b,omitzero" cbor:"9,keyasint"` // deprecated in favor of BandwidthBytes
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
Podman bool `json:"p,omitzero" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
GpuPct float64 `json:"g,omitzero" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitzero" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitzero" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
// LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
// LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ConnectionType ConnectionType `json:"ct,omitzero" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name)
PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown)
WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces
SystemdLogs bool `json:"jl,omitempty" cbor:"27,keyasint,omitempty"` // agent can read the system journal
SystemdLogs bool `json:"jl,omitzero" cbor:"27,keyasint,omitempty"` // agent can read the system journal
}
// Data that does not change during process lifetime and is not needed in All Systems table
@@ -222,6 +222,6 @@ type CombinedData struct {
Details *Details `cbor:"4,keyasint,omitempty"`
// SystemdServicesUpdated distinguishes a fresh empty snapshot from a response
// that omitted systemd data (for example, a short-cache dashboard request).
SystemdServicesUpdated bool `json:"systemdUpdated,omitempty" cbor:"5,keyasint,omitempty"`
SystemdServicesUpdated bool `json:"systemdUpdated,omitzero" cbor:"5,keyasint,omitempty"`
Monitors map[string]monitor.Result `cbor:"6,keyasint"`
}
+54
View File
@@ -7,6 +7,8 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/zfs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -36,6 +38,29 @@ func TestStatsBatteryTransport(t *testing.T) {
assert.Equal(t, stats.Batteries, decoded.Batteries)
}
// GPUs without a package power reading (e.g. NVIDIA) must not get a "pp" key,
// otherwise the UI shows an empty package power series (PocketBase uses json/v2)
func TestGPUDataPowerPkgOmittedWhenZero(t *testing.T) {
gpus := map[string]GPUData{
"0": {Name: "GeForce RTX 4060", Power: 91.21},
"1": {Name: "Arc A380", Power: 10, PowerPkg: 20},
}
for name, marshal := range map[string]func(any) ([]byte, error){
"json_v1": json.Marshal,
"json_v2": func(value any) ([]byte, error) { return jsonv2.Marshal(value) },
} {
t.Run(name, func(t *testing.T) {
jsonData, err := marshal(gpus)
require.NoError(t, err)
var jsonPayload map[string]map[string]any
require.NoError(t, json.Unmarshal(jsonData, &jsonPayload))
assert.NotContains(t, jsonPayload["0"], "pp")
assert.Equal(t, float64(20), jsonPayload["1"]["pp"])
})
}
}
func TestStatsDiskIOTotalAndFansTransport(t *testing.T) {
stats := Stats{
DiskIOTotal: [2]uint64{437348527104, 331522465792},
@@ -117,3 +142,32 @@ func TestCombinedDataContainerValidityTransport(t *testing.T) {
require.NoError(t, cbor.Unmarshal(invalidData, &decodedInvalid))
assert.Nil(t, decodedInvalid.Containers)
}
// PocketBase marshals record JSON fields with encoding/json/v2, where omitempty
// no longer omits zero numbers or false. Scalar fields must use omitzero so
// stored records keep the same shape as encoding/json (v1).
func TestZeroValueJSONMatchesV1(t *testing.T) {
for name, value := range map[string]any{
"stats": Stats{
ExtraFs: map[string]*FsStats{"sda": {}},
GPUData: map[string]GPUData{"0": {}},
ZfsPools: map[string]*ZfsPool{"tank": {}},
},
"info": Info{WiFi: map[string]WiFi{"wlan0": {}}},
"package_updates": PackageUpdates{Packages: []PackageUpdate{{}}},
"zfs": zfs.ZfsData{Pools: []*zfs.PoolDetail{{
Scrub: &zfs.Scrub{State: "NONE"}, // a scrub encoding to {} is dropped by v2 omitempty
Vdevs: []*zfs.Vdev{{}},
Datasets: []*zfs.Dataset{{}},
}}},
"smart": smart.SmartData{Attributes: []*smart.SmartAttribute{{}}},
} {
t.Run(name, func(t *testing.T) {
v1Data, err := json.Marshal(value)
require.NoError(t, err)
v2Data, err := jsonv2.Marshal(value)
require.NoError(t, err)
assert.JSONEq(t, string(v1Data), string(v2Data))
})
}
}
+12 -11
View File
@@ -6,7 +6,7 @@ import "strings"
// ZfsData is the detail payload returned by the agent for the GetZfsData action.
type ZfsData struct {
Pools []*PoolDetail `json:"pools,omitempty"`
Complete bool `json:"complete,omitempty"`
Complete bool `json:"complete,omitzero"`
// Backends whose inventories are complete, even when another backend failed.
CompleteBackends []string `json:"completeBackends,omitempty"`
}
@@ -33,12 +33,13 @@ func (data *ZfsData) CanRefreshPool(name string) bool {
// scrub, vdev, and dataset information.
type PoolDetail struct {
DisplayName string `json:"displayName,omitempty"`
Raw bool `json:"raw,omitempty"`
Raw bool `json:"raw,omitzero"`
Name string `json:"name"`
Health string `json:"health,omitempty"`
Size uint64 `json:"size,omitempty"` // bytes
Alloc uint64 `json:"alloc,omitempty"` // bytes
Free uint64 `json:"free,omitempty"` // bytes
Status string `json:"status,omitempty"`
Size uint64 `json:"size,omitzero"` // bytes
Alloc uint64 `json:"alloc,omitzero"` // bytes
Free uint64 `json:"free,omitzero"` // bytes
Scrub *Scrub `json:"scrub,omitempty"`
Vdevs []*Vdev `json:"vdevs,omitempty"`
Datasets []*Dataset `json:"datasets,omitempty"`
@@ -48,22 +49,22 @@ type PoolDetail struct {
type Scrub struct {
State string `json:"state,omitempty"` // NONE, SCANNING, FINISHED, CANCELED
Progress string `json:"progress,omitempty"`
Errors uint64 `json:"errors,omitempty"`
Errors uint64 `json:"errors,omitzero"`
}
// Vdev is a single vdev (mirror, raidz, or leaf disk) with error counters.
type Vdev struct {
Name string `json:"name"`
State string `json:"state,omitempty"`
ReadErrs uint64 `json:"readErrs,omitempty"`
WriteErrs uint64 `json:"writeErrs,omitempty"`
ChecksumErrs uint64 `json:"checksumErrs,omitempty"`
ReadErrs uint64 `json:"readErrs,omitzero"`
WriteErrs uint64 `json:"writeErrs,omitzero"`
ChecksumErrs uint64 `json:"checksumErrs,omitzero"`
}
// Dataset is a single ZFS dataset with usage information.
type Dataset struct {
Name string `json:"name"`
Used uint64 `json:"used,omitempty"`
Avail uint64 `json:"avail,omitempty"`
Used uint64 `json:"used,omitzero"`
Avail uint64 `json:"avail,omitzero"`
Mountpoint string `json:"mount,omitempty"`
}
+1 -1
View File
@@ -14,7 +14,7 @@ type PublicAppInfo struct {
BASE_PATH string
HUB_VERSION string
HUB_URL string
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitzero"`
}
// modifyIndexHTML injects the public app information into the index.html content
+18
View File
@@ -174,6 +174,13 @@ func (sys *System) update() error {
data, err := sys.fetchDataFromAgent(options)
if err != nil {
// Ping so a slow agent's connection survives until the next poll. Ping()
// extends the read deadline without needing a reply (see ws.deadline).
// Once the agent has been silent for maxAgentSilence, stop pinging and let
// the deadline close what is likely a dead connection.
if sys.WsConn != nil && sys.WsConn.IsConnected() && sys.WsConn.TimeSinceLastMessage() < maxAgentSilence {
_ = sys.WsConn.Ping()
}
return err
}
@@ -611,8 +618,13 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
// Other errors, like a query timing out while the hub is stalled, leave the
// system in place so the updater retries on the next tick (issue #2544).
func (sys *System) getRecord(app core.App) (*core.Record, error) {
record, err := app.FindRecordById("systems", sys.Id)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
if err == nil {
@@ -800,6 +812,12 @@ func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*syste
// so this must be well above the request manager's 5s default.
var wsDataRequestTimeout = 30 * time.Second
// maxAgentSilence is how long a WebSocket agent may go without sending anything
// before failed polls stop keeping its connection alive. An agent whose
// collection runs past its own 120s deadline drops the connection itself, so a
// live agent always answers within interval + 120s.
var maxAgentSilence = 3 * time.Minute
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
@@ -0,0 +1,57 @@
//go:build testing
package systems
import (
"context"
"testing"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// failingRecordHub fails record lookups with err, like a query that times out
// while the hub's VM is frozen.
type failingRecordHub struct {
stubHub
err error
}
func (h failingRecordHub) FindRecordById(collectionModelOrIdentifier any, recordId string, optFilters ...func(q *dbx.SelectQuery) error) (*core.Record, error) {
return nil, h.err
}
func TestGetRecordKeepsSystemOnTransientError(t *testing.T) {
sys, app := newTestSystemWithHub(t)
sm := NewSystemManager(failingRecordHub{stubHub: stubHub{app}, err: context.DeadlineExceeded})
t.Cleanup(sm.cancel)
sys.manager = sm
sys.ctx, sys.cancel = sys.getContext(sm.ctx)
sm.systems.Set(sys.Id, sys)
record, err := sys.getRecord(sm.hub)
require.ErrorIs(t, err, context.DeadlineExceeded)
assert.Nil(t, record)
assert.True(t, sm.HasSystem(sys.Id), "system should stay in the manager")
assert.NoError(t, sys.ctx.Err(), "updater context should not be cancelled")
}
func TestGetRecordRemovesDeletedSystem(t *testing.T) {
sys, app := newTestSystemWithHub(t)
sm := NewSystemManager(stubHub{app})
t.Cleanup(sm.cancel)
sys.manager = sm
sys.ctx, sys.cancel = sys.getContext(sm.ctx)
sm.systems.Set(sys.Id, sys)
record, err := app.FindRecordById("systems", sys.Id)
require.NoError(t, err)
require.NoError(t, app.Delete(record))
_, err = sys.getRecord(sm.hub)
require.Error(t, err)
assert.False(t, sm.HasSystem(sys.Id), "deleted system should be removed")
assert.Error(t, sys.ctx.Err(), "updater context should be cancelled")
}
+1
View File
@@ -144,6 +144,7 @@ func (sys *System) upsertZfsPoolRecord(app core.App, collection *core.Collection
record.Set("name", pool.Name)
record.Set("display_name", pool.DisplayName)
record.Set("health", pool.Health)
record.Set("status", pool.Status)
record.Set("size", pool.Size)
record.Set("alloc", pool.Alloc)
record.Set("free", pool.Free)
+2 -1
View File
@@ -99,11 +99,12 @@ func TestSaveZfsPoolsCompleteEmptyPrunesFinalPool(t *testing.T) {
sys, app := newTestSystemWithHub(t)
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{
Complete: true,
Pools: []*zfs.PoolDetail{{Name: "tank", Health: "ONLINE"}},
Pools: []*zfs.PoolDetail{{Name: "tank", Health: "ONLINE", Status: "One or more devices has experienced an unrecoverable error."}},
}))
records, err := app.FindRecordsByFilter("zfs_pools", "system={:system}", "", 0, 0, map[string]any{"system": sys.Id})
require.NoError(t, err)
require.Len(t, records, 1)
assert.Equal(t, "One or more devices has experienced an unrecoverable error.", records[0].GetString("status"))
assert.False(t, records[0].GetDateTime("details_updated").Time().IsZero())
require.NoError(t, sys.saveZfsPools(&zfs.ZfsData{Complete: true}))
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
@@ -80,3 +81,189 @@ func TestFetchDataTimeoutKeepsWebSocketOpen(t *testing.T) {
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
require.NoError(t, err)
}
// pingCountingClient never answers GetData and records the pings it receives,
// standing in for an agent whose collection outlasts the request timeout.
type pingCountingClient struct {
gws.BuiltinEventHandler
pings chan struct{}
}
func (c *pingCountingClient) OnPing(conn *gws.Conn, payload []byte) {
_ = conn.WritePong(payload)
select {
case c.pings <- struct{}{}:
default:
}
}
func (c *pingCountingClient) OnMessage(conn *gws.Conn, message *gws.Message) {
// Swallow the request: this agent is still collecting.
message.Close()
}
// A slow agent used to be disconnected between polls. The fetch gives up after
// wsDataRequestTimeout, but the hub's read deadline is longer and only traffic
// from the agent resets it, so nothing kept the connection alive in between.
// update() now pings on a failed fetch, which is what breaks the reconnect
// loop reported in #2294.
func TestUpdatePingsAgentAfterFailedFetch(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 := &pingCountingClient{pings: make(chan struct{}, 4)}
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.update()
require.ErrorIs(t, err, context.DeadlineExceeded, "the fetch is expected to time out")
select {
case <-client.pings:
case <-time.After(3 * time.Second):
t.Fatal("a failed fetch must ping the agent to keep the connection alive")
}
require.True(t, sys.WsConn.IsConnected(), "the connection must survive a failed fetch")
}
// silentAfterFirstPollAgent answers the first GetData and then goes quiet,
// modelling an agent whose collection outlasts the request timeout. It never
// answers a ping, because a real agent serves hub requests on its read loop and
// cannot reply while it is collecting. The fix therefore has to work without an
// answer, which it does: Ping() pushes the deadline out as it sends.
type silentAfterFirstPollAgent struct {
gws.BuiltinEventHandler
answered atomic.Bool
}
func (c *silentAfterFirstPollAgent) OnPing(*gws.Conn, []byte) {}
func (c *silentAfterFirstPollAgent) 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
}
if c.answered.CompareAndSwap(false, true) {
response, _ := cbor.Marshal(common.AgentResponse{Id: req.Id, SystemData: &esystem.CombinedData{}})
_ = conn.WriteMessage(gws.OpcodeBinary, response)
}
}
// timelineScale shrinks the poll schedule so the test stays fast while keeping
// the production ratio between poll interval, request timeout and read deadline.
const timelineScale = 100
// A slow agent must survive repeated failed polls. The ping in update() only
// happens once the fetch gives up, which is interval + wsDataRequestTimeout into
// the cycle, so the read deadline has to outlast that. With a deadline below
// that sum the connection is already closed when the ping would go out, which is
// the reconnect loop from #2294. An agent silent for longer than
// maxAgentSilence is no longer pinged, so its connection is allowed to expire.
func TestSlowAgentKeepsConnectionAcrossPolls(t *testing.T) {
tick := time.Duration(interval) * time.Millisecond / timelineScale
tests := []struct {
name string
deadline time.Duration
polls int
wantConnected bool
}{
// The first failed poll shows the ping lands, the second shows the ping
// keeps re-arming the deadline.
{"production deadline outlasts a failed poll", ws.Deadline() / timelineScale, 2, true},
// 70s was the old value and sits below the 90s a failed poll needs.
{"deadline below interval plus request timeout", 70 * time.Second / timelineScale, 2, false},
// The third failed poll ends 210s after the last message, past
// maxAgentSilence, so it skips the ping and the deadline closes the
// connection before the fifth poll.
{"agent silent past maxAgentSilence", ws.Deadline() / timelineScale, 5, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
previousDeadline := ws.SetDeadline(tc.deadline)
t.Cleanup(func() { ws.SetDeadline(previousDeadline) })
previousTimeout := wsDataRequestTimeout
wsDataRequestTimeout = 30 * time.Second / timelineScale
t.Cleanup(func() { wsDataRequestTimeout = previousTimeout })
previousSilence := maxAgentSilence
maxAgentSilence = 3 * time.Minute / timelineScale
t.Cleanup(func() { maxAgentSilence = previousSilence })
connections := make(chan *ws.WsConn, 1)
// The real handler: OnClose has to run, or a dropped connection
// would still look connected.
upgrader := gws.NewUpgrader(&ws.Handler{}, &gws.ServerOption{})
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 := &silentAfterFirstPollAgent{}
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")
}
// A healthy poll. Its response is what last resets the deadline, so
// it is the point the schedule below is measured from.
_, err = sys.fetchDataFromAgent(common.DataRequestOptions{})
require.NoError(t, err, "the first poll must succeed")
pollZero := time.Now()
for poll := 1; poll <= tc.polls; poll++ {
time.Sleep(time.Until(pollZero.Add(time.Duration(poll) * tick)))
if !sys.WsConn.IsConnected() {
break
}
require.Error(t, sys.update(), "a silent agent must fail the poll")
}
require.Equal(t, tc.wantConnected, sys.WsConn.IsConnected())
})
}
}
+13
View File
@@ -3,6 +3,7 @@ package transport
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
@@ -40,3 +41,15 @@ func TestUnmarshalSystemdLogsResponse(t *testing.T) {
require.NoError(t, UnmarshalResponse(response, common.GetSystemdLogs, &result))
assert.Equal(t, logs, result)
}
func TestUnmarshalEmptySmartDataResponse(t *testing.T) {
payload, err := cbor.Marshal(common.AgentResponse{SmartData: map[string]smart.SmartData{}})
require.NoError(t, err)
var response common.AgentResponse
require.NoError(t, cbor.Unmarshal(payload, &response))
var result smart.SmartDataResponse
require.NoError(t, UnmarshalResponse(response, common.GetSmartData, &result))
assert.NotNil(t, result.Data)
assert.Empty(t, result.Data)
}
+16 -3
View File
@@ -16,9 +16,14 @@ import (
"github.com/lxzan/gws"
)
const (
deadline = 70 * time.Second
)
// deadline bounds how long a connection may go without traffic from the agent.
//
// It has to outlast a whole failed poll: the updater polls every interval (60s)
// and a fetch gives up after wsDataRequestTimeout (30s), so nothing can reset
// the deadline before 90s into a stalled cycle. A shorter deadline expires
// mid-collection, and by the time the failed fetch pings, the connection is
// already closed. 100s keeps the same 10s margin the poll interval used to have.
var deadline = 100 * time.Second
// Handler implements the WebSocket event handler for agent connections.
type Handler struct {
@@ -31,6 +36,7 @@ type WsConn struct {
requestManager *RequestManager
DownChan chan struct{}
agentVersion semver.Version
lastMessage atomic.Int64 // unix nanoseconds of the last message from the agent
}
// FingerprintRecord is fingerprints collection record data in the hub
@@ -61,6 +67,7 @@ func NewWsConnection(conn *gws.Conn, agentVersion semver.Version) *WsConn {
agentVersion: agentVersion,
}
ws.conn.Store(conn)
ws.lastMessage.Store(time.Now().UnixNano())
return ws
}
@@ -80,6 +87,7 @@ func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
_ = conn.WriteClose(1000, nil)
return
}
wsConn.(*WsConn).lastMessage.Store(time.Now().UnixNano())
wsConn.(*WsConn).requestManager.handleResponse(message)
}
@@ -121,6 +129,11 @@ func (ws *WsConn) Ping() error {
return conn.WritePing(nil)
}
// TimeSinceLastMessage returns how long it has been since the agent last sent a message.
func (ws *WsConn) TimeSinceLastMessage() time.Duration {
return time.Since(time.Unix(0, ws.lastMessage.Load()))
}
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
// This is kept for backwards compatibility but new actions should use RequestManager.
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
+8 -2
View File
@@ -170,9 +170,15 @@ func TestFingerprintRecord(t *testing.T) {
assert.Equal(t, "test-token", record.Token)
}
// TestDeadlineConstant tests that the deadline constant is reasonable
// TestDeadlineConstant tests that the deadline is reasonable.
//
// It has to stay above the 90s a failed poll takes in the systems package (a
// 60s poll interval plus a 30s request timeout), because the keepalive ping
// only goes out once that fetch gives up. Lowering this reopens the reconnect
// loop from #2294; TestSlowAgentKeepsConnectionAcrossPolls covers the effect.
func TestDeadlineConstant(t *testing.T) {
assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds")
assert.Equal(t, 100*time.Second, deadline, "Deadline should be 100 seconds")
assert.Greater(t, deadline, 90*time.Second, "Deadline must outlast a failed poll")
}
// TestCommonActions tests that the common actions are properly defined
+13
View File
@@ -2,9 +2,22 @@
package ws
import "time"
// GetPendingCount returns the number of pending requests (for monitoring)
func (rm *RequestManager) GetPendingCount() int {
rm.RLock()
defer rm.RUnlock()
return len(rm.pendingReqs)
}
// TESTING ONLY: Deadline reports the connection read deadline.
func Deadline() time.Duration { return deadline }
// TESTING ONLY: SetDeadline overrides the connection read deadline so tests can
// drive the poll schedule at a reduced scale. It returns the previous value.
func SetDeadline(d time.Duration) time.Duration {
previous := deadline
deadline = d
return previous
}
@@ -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("zfs_pools")
if err != nil {
return err
}
collection.Fields.Add(&core.TextField{Id: "zfs_pool_status", Name: "status"})
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("zfs_pools")
if err != nil {
return err
}
collection.Fields.RemoveByName("status")
return app.Save(collection)
})
}
+58 -4
View File
@@ -41,7 +41,7 @@
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^1.3.1",
"valibot": "^1.4.2",
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
@@ -57,7 +57,7 @@
"tailwindcss": "^4.1.12",
"tw-animate-css": "^1.3.7",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vite": "^7.3.5",
},
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5",
@@ -940,7 +940,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
"valibot": ["valibot@1.5.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -948,7 +948,7 @@
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
"vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
@@ -1004,6 +1004,8 @@
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"vite/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -1014,6 +1016,58 @@
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+954 -335
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -48,7 +48,7 @@
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^1.3.1"
"valibot": "^1.4.2"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
@@ -64,7 +64,7 @@
"tailwindcss": "^4.1.12",
"tw-animate-css": "^1.3.7",
"typescript": "^5.9.2",
"vite": "^7.1.3"
"vite": "^7.3.5"
},
"overrides": {
"@nanostores/router": {
+107 -98
View File
@@ -123,14 +123,10 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
setHostValue(system?.host ?? "")
}}
>
<Tabs defaultValue={tab} onValueChange={setTab}>
<Tabs value={tab} onValueChange={setTab}>
<DialogHeader>
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
{system ? (
<Trans>Edit System</Trans>
) : (
<Trans>Add System</Trans>
)}
{system ? <Trans>Edit System</Trans> : <Trans>Add System</Trans>}
</DialogTitle>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="docker">Docker</TabsTrigger>
@@ -139,40 +135,55 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
</TabsTrigger>
</TabsList>
</DialogHeader>
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<TabsContent value="docker" tabIndex={-1}>
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
Copy the installation command for the agent below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
{/* Keep both translations in the same grid cell so tab changes do not resize the dialog. */}
<DialogDescription asChild className="mb-3 leading-relaxed">
<div className="grid">
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<TabsContent
value="docker"
forceMount
tabIndex={-1}
className="col-start-1 row-start-1 data-[state=inactive]:invisible"
>
<p className="w-0 min-w-full">
<Trans>
Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</p>
</TabsContent>
{/* Binary */}
<TabsContent
value="binary"
forceMount
tabIndex={-1}
className="col-start-1 row-start-1 data-[state=inactive]:invisible"
>
<p className="w-0 min-w-full">
<Trans>
Copy the installation command for the agent below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</p>
</TabsContent>
</div>
</DialogDescription>
<form onSubmit={handleSubmit as any}>
<div className="grid xs:grid-cols-[auto_1fr] gap-y-3 gap-x-4 items-center mt-1 mb-4">
<Label htmlFor="name" className="xs:text-end">
@@ -212,65 +223,63 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" className="contents">
<CopyButton
text={t`Copy Linux command`}
icon={<TuxIcon className="size-4" />}
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [FreeBsdIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]}
/>
</TabsContent>
<div className="grid">
{/* Docker */}
<div className={cn("col-start-1 row-start-1", tab !== "docker" && "invisible")}>
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
</div>
{/* Binary */}
<div className={cn("col-start-1 row-start-1", tab !== "binary" && "invisible")}>
<CopyButton
text={t`Copy Linux command`}
icon={<TuxIcon className="size-4" />}
onClick={async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true),
icons: [AppleIcon, TuxIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t({ message: "FreeBSD command", context: "Button to copy install command" }),
onClick: async () =>
copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [FreeBsdIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [ExternalLinkIcon],
},
]}
/>
</div>
</div>
{/* Save */}
<Button>
{system ? (
<Trans>Save System</Trans>
) : (
<Trans>Add System</Trans>
)}
</Button>
<Button>{system ? <Trans>Save System</Trans> : <Trans>Add System</Trans>}</Button>
</DialogFooter>
</form>
</Tabs>
+1 -1
View File
@@ -103,7 +103,7 @@ export function useYAxisWidth() {
maxChars = str.length
const div = document.createElement("div")
div.className = "text-xs tabular-nums tracking-tighter table sr-only"
div.innerHTML = str
div.textContent = str
clearTimeout(timeout)
timeout = setTimeout(() => {
document.body.appendChild(div)
@@ -42,13 +42,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { RawCapacityLabel } from "./raw-capacity-label"
const ZFS_POOL_FIELDS = "id,system,name,display_name,health,size,alloc,free,raw,scrub,details_updated,updated"
const ZFS_POOL_FIELDS = "id,system,name,display_name,health,size,alloc,free,raw,scrub,vdevs,details_updated,updated"
/** Maps a zpool health string to a Badge variant. */
function healthVariant(health: string): "success" | "warning" | "danger" | "outline" {
/** Reports whether any vdev has nonzero read, write, or checksum errors. */
function hasVdevErrors(vdevs?: ZfsVdev[] | null): boolean {
return !!vdevs?.some((vdev) => vdev.readErrs || vdev.writeErrs || vdev.checksumErrs)
}
/** Maps a zpool health string to a Badge variant. An ONLINE pool with vdev
* error counters (e.g. corrected checksum errors) is shown as a warning. */
function healthVariant(health: string, hasErrors = false): "success" | "warning" | "danger" | "outline" {
switch (health) {
case "ONLINE":
return "success"
return hasErrors ? "warning" : "success"
case "DEGRADED":
return "warning"
case "FAULTED":
@@ -113,9 +119,9 @@ const columns: ColumnDef<ZfsPoolRecord>[] = [
accessorKey: "health",
sortingFn: (a, b) => a.original.health.localeCompare(b.original.health),
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ActivityIcon} />,
cell: ({ getValue }) => {
cell: ({ getValue, row }) => {
const health = (getValue() as string) || ""
return <Badge variant={healthVariant(health)}>{health || t`Unknown`}</Badge>
return <Badge variant={healthVariant(health, hasVdevErrors(row.original.vdevs))}>{health || t`Unknown`}</Badge>
},
},
{
@@ -354,7 +360,7 @@ function PoolSheet({
}, [open, poolId])
const health = pool?.health || ""
const healthVariantValue = healthVariant(health)
const healthVariantValue = healthVariant(health, hasVdevErrors(pool?.vdevs))
const HealthIcon =
healthVariantValue === "success"
? CheckCircleIcon
@@ -410,6 +416,7 @@ function PoolSheet({
<AlertTitle>
<Trans>Pool Health</Trans>: {health}
</AlertTitle>
{pool.status && <AlertDescription>{pool.status}</AlertDescription>}
{pool.scrub?.state && (
<AlertDescription>
Scrub: {pool.scrub.state}
@@ -143,6 +143,11 @@ export function useSystemData(id: string) {
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
let unsub = () => {}
// subscribe() resolves only after the server confirms the subscription. If the
// effect is cleaned up before that (e.g. switching back to 1h quickly, or a slow
// realtime connection behind a proxy), unsub is still the no-op above, so the
// subscription would stay active and its 1m points would overwrite the history.
let cancelled = false
if (!system.id || chartTime !== "1m") {
return
}
@@ -156,6 +161,9 @@ export function useSystemData(id: string) {
.subscribe(
`rt_metrics`,
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
if (cancelled) {
return
}
const now = Date.now()
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
const containerPoint =
@@ -177,9 +185,14 @@ export function useSystemData(id: string) {
{ query: { system: system.id } }
)
.then((us) => {
if (cancelled) {
us()
return
}
unsub = us
})
return () => {
cancelled = true
unsub?.()
}
}, [chartTime, system.id])
@@ -476,7 +476,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
},
cell(info) {
const sys = info.row.original
if (sys.status !== SystemStatus.Up || !sys.info.pu) {
if (!sys.info.pu) {
return null
}
const [total, security = 0] = sys.info.pu
@@ -487,6 +487,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
[STATUS_COLORS[SystemStatus.Down]]: security > 0,
[STATUS_COLORS[SystemStatus.Pending]]: security === 0 && total > 0,
[STATUS_COLORS[SystemStatus.Up]]: total === 0,
[STATUS_COLORS[SystemStatus.Paused]]: sys.status !== SystemStatus.Up,
})}
/>
{total === 0 ? t`Up to date` : plural(total, { one: "# update", other: "# updates" })}
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { classifyVersionChange } from "./package-updates"
// version pairs are taken from real apt, dnf, zypper, pacman and apk output
// version pairs are taken from real apt, dnf, zypper, pacman, apk and xbps output
test("major, minor and patch use the first differing upstream component", () => {
expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major")
expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor")
@@ -10,6 +10,7 @@ test("major, minor and patch use the first differing upstream component", () =>
expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch")
expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch")
expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch")
expect(classifyVersionChange("26.2.3_1", "26.2.4_1")).toBe("patch")
// missing components count as zero
expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch")
expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor")
@@ -32,6 +33,7 @@ test("revision-only changes", () => {
expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision")
expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision")
expect(classifyVersionChange("42-30", "42-31")).toBe("revision")
expect(classifyVersionChange("12.3.2_1", "12.3.2_2")).toBe("revision")
expect(
classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1")
).toBe("revision")
+8 -4
View File
@@ -16,7 +16,8 @@ interface SplitVersion {
/**
* Splits a distro version string into epoch, upstream version and packaging revision.
* Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1")
* and apk ("1.2.5-r3") formats. The revision follows the last "-".
* apk ("1.2.5-r3") and xbps ("26.2.4_1") formats. The revision follows the last "-",
* or the last "_" for xbps, whose versions have no "-".
*/
function splitVersion(version: string): SplitVersion {
let epoch = 0
@@ -25,9 +26,12 @@ function splitVersion(version: string): SplitVersion {
epoch = Number(epochMatch[1])
version = version.slice(epochMatch[0].length)
}
const dash = version.lastIndexOf("-")
if (dash > 0) {
return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) }
let sep = version.lastIndexOf("-")
if (sep < 0) {
sep = version.lastIndexOf("_")
}
if (sep > 0) {
return { epoch, upstream: version.slice(0, sep), revision: version.slice(sep + 1) }
}
return { epoch, upstream: version, revision: "" }
}
+2
View File
@@ -260,6 +260,8 @@ export interface ZfsPoolRecord extends RecordModel {
system: string
name: string
health: string
/** zpool status: advisory message, when the pool carries one */
status?: string
size: number
alloc: number
free: number