mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-22 06:12:33 +00:00
Merge remote-tracking branch 'upstream/main' into fix/package-updates-sandbox
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = ©Device
|
||||
}
|
||||
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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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=="],
|
||||
|
||||
Generated
+954
-335
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: "" }
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user