diff --git a/agent/client.go b/agent/client.go index c90f2c48d..ca42978e9 100644 --- a/agent/client.go +++ b/agent/client.go @@ -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 } diff --git a/agent/client_test.go b/agent/client_test.go index 06d6c1a76..37ee8a60e 100644 --- a/agent/client_test.go +++ b/agent/client_test.go @@ -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) +} diff --git a/agent/connection_manager.go b/agent/connection_manager.go index c52a2d288..88f3a6511 100644 --- a/agent/connection_manager.go +++ b/agent/connection_manager.go @@ -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") diff --git a/agent/cpu.go b/agent/cpu.go index f99f8ca40..9db50b7f0 100644 --- a/agent/cpu.go +++ b/agent/cpu.go @@ -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 diff --git a/agent/cpu_test.go b/agent/cpu_test.go index 83b025616..3fb722ac6 100644 --- a/agent/cpu_test.go +++ b/agent/cpu_test.go @@ -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) +} diff --git a/agent/docker.go b/agent/docker.go index ee7700497..8b669d14d 100644 --- a/agent/docker.go +++ b/agent/docker.go @@ -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 { diff --git a/agent/docker_test.go b/agent/docker_test.go index 76484d545..8d182a205 100644 --- a/agent/docker_test.go +++ b/agent/docker_test.go @@ -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") diff --git a/agent/gpu_intel.go b/agent/gpu_intel.go index b0813ffa1..43257689c 100644 --- a/agent/gpu_intel.go +++ b/agent/gpu_intel.go @@ -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. diff --git a/agent/gpu_nvml.go b/agent/gpu_nvml.go index a90c9c048..044de663d 100644 --- a/agent/gpu_nvml.go +++ b/agent/gpu_nvml.go @@ -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 +} diff --git a/agent/gpu_nvml_sample_test.go b/agent/gpu_nvml_sample_test.go new file mode 100644 index 000000000..b7c7e3315 --- /dev/null +++ b/agent/gpu_nvml_sample_test.go @@ -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) + } +} diff --git a/agent/gpu_test.go b/agent/gpu_test.go index 0636dc259..e87b4aa27 100644 --- a/agent/gpu_test.go +++ b/agent/gpu_test.go @@ -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`), diff --git a/agent/network.go b/agent/network.go index c980a277a..d196c74b7 100644 --- a/agent/network.go +++ b/agent/network.go @@ -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 } diff --git a/agent/network_test.go b/agent/network_test.go index f04910992..6d3a1ce0f 100644 --- a/agent/network_test.go +++ b/agent/network_test.go @@ -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"]) } diff --git a/agent/package_updates.go b/agent/package_updates.go index 97382a2a3..4e81c37e4 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -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:] +} diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index eed8a93bf..7bc1e368c 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -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 { diff --git a/agent/smart.go b/agent/smart.go index 3490e7fb0..57dae3a8a 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -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 diff --git a/agent/smart_test.go b/agent/smart_test.go index 8b3071c8e..c7b7ee960 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -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) + }) + } +} diff --git a/agent/storage_pool.go b/agent/storage_pool.go index 0937117e8..7cf12dcb4 100644 --- a/agent/storage_pool.go +++ b/agent/storage_pool.go @@ -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 } diff --git a/agent/storage_pool_test.go b/agent/storage_pool_test.go index 5fe9f8e27..528034315 100644 --- a/agent/storage_pool_test.go +++ b/agent/storage_pool_test.go @@ -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) diff --git a/agent/systemd.go b/agent/systemd.go index d731ded84..d24d77910 100644 --- a/agent/systemd.go +++ b/agent/systemd.go @@ -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} diff --git a/agent/systemd_test.go b/agent/systemd_test.go index 4947805a3..d8c6a7353 100644 --- a/agent/systemd_test.go +++ b/agent/systemd_test.go @@ -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") diff --git a/agent/test-data/package_updates/xbps_void_check_update.txt b/agent/test-data/package_updates/xbps_void_check_update.txt new file mode 100644 index 000000000..27e9405fb --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_check_update.txt @@ -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 diff --git a/agent/test-data/package_updates/xbps_void_install_update.txt b/agent/test-data/package_updates/xbps_void_install_update.txt new file mode 100644 index 000000000..d07615fa2 --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_install_update.txt @@ -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 diff --git a/agent/test-data/package_updates/xbps_void_query_installed.txt b/agent/test-data/package_updates/xbps_void_query_installed.txt new file mode 100644 index 000000000..ae4459607 --- /dev/null +++ b/agent/test-data/package_updates/xbps_void_query_installed.txt @@ -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 diff --git a/agent/test-data/zfs/zpool_status.txt b/agent/test-data/zfs/zpool_status.txt index e25076c84..b52ce344d 100644 --- a/agent/test-data/zfs/zpool_status.txt +++ b/agent/test-data/zfs/zpool_status.txt @@ -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 diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go index 0cd224d6b..011a0804a 100644 --- a/agent/zfs/zfs.go +++ b/agent/zfs/zfs.go @@ -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 diff --git a/agent/zfs/zfs_linux_test.go b/agent/zfs/zfs_linux_test.go index 41f216ba6..96a97cca3 100644 --- a/agent/zfs/zfs_linux_test.go +++ b/agent/zfs/zfs_linux_test.go @@ -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) + }) + } } diff --git a/agent/zfs/zfs_nonlinux_test.go b/agent/zfs/zfs_nonlinux_test.go index b7e71c42e..5c0529513 100644 --- a/agent/zfs/zfs_nonlinux_test.go +++ b/agent/zfs/zfs_nonlinux_test.go @@ -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) diff --git a/agent/zfs/zfs_status.go b/agent/zfs/zfs_status.go index 052d7e723..22050e406 100644 --- a/agent/zfs/zfs_status.go +++ b/agent/zfs/zfs_status.go @@ -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 { diff --git a/agent/zfs/zfs_test.go b/agent/zfs/zfs_test.go index 0472434d9..f1a524c17 100644 --- a/agent/zfs/zfs_test.go +++ b/agent/zfs/zfs_test.go @@ -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) { diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 496a11608..f0ad7bec0 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -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"` } diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index d6584ccb9..91ea5490c 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -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"` diff --git a/internal/entities/container/container.go b/internal/entities/container/container.go index 7105c4af9..aa980d183 100644 --- a/internal/entities/container/container.go +++ b/internal/entities/container/container.go @@ -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 { diff --git a/internal/entities/smart/smart.go b/internal/entities/smart/smart.go index 694522e59..0f961e42f 100644 --- a/internal/entities/smart/smart.go +++ b/internal/entities/smart/smart.go @@ -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"` diff --git a/internal/entities/system/package_updates.go b/internal/entities/system/package_updates.go index c07fe130a..73b43f16d 100644 --- a/internal/entities/system/package_updates.go +++ b/internal/entities/system/package_updates.go @@ -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"` } diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 29bb9f675..fafa1d381 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -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"` } diff --git a/internal/entities/system/system_test.go b/internal/entities/system/system_test.go index 7a7f00494..d8addf71a 100644 --- a/internal/entities/system/system_test.go +++ b/internal/entities/system/system_test.go @@ -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)) + }) + } +} diff --git a/internal/entities/zfs/zfs.go b/internal/entities/zfs/zfs.go index 99aea84a8..c5a6bd894 100644 --- a/internal/entities/zfs/zfs.go +++ b/internal/entities/zfs/zfs.go @@ -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"` } diff --git a/internal/hub/server.go b/internal/hub/server.go index 3e4f64d02..0d55bbf57 100644 --- a/internal/hub/server.go +++ b/internal/hub/server.go @@ -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 diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 0a292668d..d03d2bccb 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -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") diff --git a/internal/hub/systems/system_get_record_test.go b/internal/hub/systems/system_get_record_test.go new file mode 100644 index 000000000..24d7c2249 --- /dev/null +++ b/internal/hub/systems/system_get_record_test.go @@ -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") +} diff --git a/internal/hub/systems/system_zfs.go b/internal/hub/systems/system_zfs.go index 9d04d444e..9f6405290 100644 --- a/internal/hub/systems/system_zfs.go +++ b/internal/hub/systems/system_zfs.go @@ -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) diff --git a/internal/hub/systems/system_zfs_test.go b/internal/hub/systems/system_zfs_test.go index 07ffa0eee..3dbfc02a7 100644 --- a/internal/hub/systems/system_zfs_test.go +++ b/internal/hub/systems/system_zfs_test.go @@ -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})) diff --git a/internal/hub/systems/ws_data_timeout_test.go b/internal/hub/systems/ws_data_timeout_test.go index ae6720fd2..c50cd4632 100644 --- a/internal/hub/systems/ws_data_timeout_test.go +++ b/internal/hub/systems/ws_data_timeout_test.go @@ -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()) + }) + } +} diff --git a/internal/hub/transport/transport_test.go b/internal/hub/transport/transport_test.go index 12328fc8f..c4536b7fc 100644 --- a/internal/hub/transport/transport_test.go +++ b/internal/hub/transport/transport_test.go @@ -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) +} diff --git a/internal/hub/ws/ws.go b/internal/hub/ws/ws.go index 7d61e596c..c040c8190 100644 --- a/internal/hub/ws/ws.go +++ b/internal/hub/ws/ws.go @@ -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 { diff --git a/internal/hub/ws/ws_test.go b/internal/hub/ws/ws_test.go index 9970cf47b..caa53b342 100644 --- a/internal/hub/ws/ws_test.go +++ b/internal/hub/ws/ws_test.go @@ -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 diff --git a/internal/hub/ws/ws_test_helpers.go b/internal/hub/ws/ws_test_helpers.go index ac52c779d..aaf49dc96 100644 --- a/internal/hub/ws/ws_test_helpers.go +++ b/internal/hub/ws/ws_test_helpers.go @@ -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 +} diff --git a/internal/migrations/1790924727_zfs_pool_status.go b/internal/migrations/1790924727_zfs_pool_status.go new file mode 100644 index 000000000..ecdd48502 --- /dev/null +++ b/internal/migrations/1790924727_zfs_pool_status.go @@ -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) + }) +} diff --git a/internal/site/bun.lock b/internal/site/bun.lock index 34d8f2f34..b9fddc6ea 100644 --- a/internal/site/bun.lock +++ b/internal/site/bun.lock @@ -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=="], diff --git a/internal/site/package-lock.json b/internal/site/package-lock.json index aa6143091..b4bd0c215 100644 --- a/internal/site/package-lock.json +++ b/internal/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "beszel", - "version": "0.18.7", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beszel", - "version": "0.18.7", + "version": "0.21.0", "dependencies": { "@henrygd/queue": "^1.0.7", "@henrygd/semaphore": "^0.0.2", @@ -44,7 +44,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", @@ -60,34 +60,20 @@ "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" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -96,9 +82,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "devOptional": true, "license": "MIT", "engines": { @@ -106,22 +92,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", - "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "devOptional": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.3", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -137,14 +123,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", - "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.3", - "@babel/types": "^7.28.2", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -154,14 +140,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -171,9 +157,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "devOptional": true, "license": "MIT", "engines": { @@ -181,29 +167,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -213,9 +199,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "devOptional": true, "license": "MIT", "engines": { @@ -223,9 +209,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "devOptional": true, "license": "MIT", "engines": { @@ -233,9 +219,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "devOptional": true, "license": "MIT", "engines": { @@ -243,27 +229,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", - "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", - "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -285,33 +271,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", - "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.3", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.3", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -319,14 +305,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "devOptional": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1063,7 +1049,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1099,28 +1085,70 @@ } }, "node_modules/@lingui/babel-plugin-extract-messages": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-extract-messages/-/babel-plugin-extract-messages-5.4.1.tgz", - "integrity": "sha512-sjkVaLyuK3ZW62mv5gU6pOdl3ZpwDReeSaNodJuf9LssbMIQPa5WOirTnMeBaalrQ8BA2srrRzQAWgsXPQVdXA==", + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-extract-messages/-/babel-plugin-extract-messages-5.9.5.tgz", + "integrity": "sha512-XOAXMPOkpy45784q5bCNN5PizoAecxkBm8kv8CEusI/f9kR3vMCcpH4kvSchU05JkKAVE8eIsdxb2zM6eDJTeA==", "dev": true, "license": "MIT", "engines": { "node": ">=20.0.0" } }, - "node_modules/@lingui/babel-plugin-lingui-macro": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-lingui-macro/-/babel-plugin-lingui-macro-5.4.1.tgz", - "integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==", - "devOptional": true, + "node_modules/@lingui/cli": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/cli/-/cli-5.9.5.tgz", + "integrity": "sha512-gonY7U75nzKic8GvEciy1/otQv1WpfwGW5wGMjmBXUMaMnIsycm/wo3t0+2hzqFp+RNfEKZcScoM7aViK3XuLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.0", + "@babel/generator": "^7.21.1", + "@babel/parser": "^7.22.0", + "@babel/runtime": "^7.21.0", + "@babel/types": "^7.21.2", + "@lingui/babel-plugin-extract-messages": "5.9.5", + "@lingui/babel-plugin-lingui-macro": "5.9.5", + "@lingui/conf": "5.9.5", + "@lingui/core": "5.9.5", + "@lingui/format-po": "5.9.5", + "@lingui/message-utils": "5.9.5", + "chokidar": "3.5.1", + "cli-table": "^0.3.11", + "commander": "^10.0.0", + "convert-source-map": "^2.0.0", + "date-fns": "^3.6.0", + "esbuild": "^0.25.1", + "glob": "^11.0.0", + "micromatch": "^4.0.7", + "ms": "^2.1.3", + "normalize-path": "^3.0.0", + "ora": "^5.1.0", + "picocolors": "^1.1.1", + "pofile": "^1.1.4", + "pseudolocale": "^2.0.0", + "source-map": "^0.7.6", + "threads": "^1.7.0" + }, + "bin": { + "lingui": "dist/lingui.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@lingui/cli/node_modules/@lingui/babel-plugin-lingui-macro": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-lingui-macro/-/babel-plugin-lingui-macro-5.9.5.tgz", + "integrity": "sha512-TDIrOa2hAz8kXrZ0JfMGaIiFIE4TEdqI2he4OpkTSCfBh3ec/gSCn1kNW5HdviO7x46Gvy567YOgHNOI9/e4Fg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.20.12", "@babel/runtime": "^7.20.13", "@babel/types": "^7.20.7", - "@lingui/conf": "5.4.1", - "@lingui/core": "5.4.1", - "@lingui/message-utils": "5.4.1" + "@lingui/conf": "5.9.5", + "@lingui/core": "5.9.5", + "@lingui/message-utils": "5.9.5" }, "engines": { "node": ">=20.0.0" @@ -1134,66 +1162,61 @@ } } }, - "node_modules/@lingui/cli": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/cli/-/cli-5.4.1.tgz", - "integrity": "sha512-UAKA9Iz4zMDJS7fzWMZ4hzQWontrTBnI5XCsPm7ttB0Ed0F4Pwph/Vu7pg4bJdiYr4d6nqEpRWd9aTxcC15/IA==", + "node_modules/@lingui/cli/node_modules/@lingui/conf": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.9.5.tgz", + "integrity": "sha512-k5r9ssOZirhS5BlqdsK5L0rzlqnHeryoJHAQIpUpeh8g5ymgpbUN7L4+4C4hAX/tddAFiCFN8boHTiu6Wbt83Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.21.0", - "@babel/generator": "^7.21.1", - "@babel/parser": "^7.22.0", - "@babel/runtime": "^7.21.0", - "@babel/types": "^7.21.2", - "@lingui/babel-plugin-extract-messages": "5.4.1", - "@lingui/babel-plugin-lingui-macro": "5.4.1", - "@lingui/conf": "5.4.1", - "@lingui/core": "5.4.1", - "@lingui/format-po": "5.4.1", - "@lingui/message-utils": "5.4.1", - "chokidar": "3.5.1", - "cli-table": "^0.3.11", - "commander": "^10.0.0", - "convert-source-map": "^2.0.0", - "date-fns": "^3.6.0", - "esbuild": "^0.25.1", - "glob": "^11.0.0", - "micromatch": "^4.0.7", - "normalize-path": "^3.0.0", - "ora": "^5.1.0", - "picocolors": "^1.1.1", - "pofile": "^1.1.4", - "pseudolocale": "^2.0.0", - "source-map": "^0.8.0-beta.0" - }, - "bin": { - "lingui": "dist/lingui.js" + "@babel/runtime": "^7.20.13", + "cosmiconfig": "^8.0.0", + "jest-validate": "^29.4.3", + "jiti": "^2.5.1", + "picocolors": "^1.1.1" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@lingui/cli/node_modules/chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "node_modules/@lingui/cli/node_modules/@lingui/core": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.9.5.tgz", + "integrity": "sha512-Y+iZq9NqnqZOqHNgPomUFP21KH/zs4oTTizWoz0AKAkBbq9T9yb1DSz/ugtBRjF1YLtKMF9tq28v3thMHANSiQ==", "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.1", - "braces": "~3.0.2", - "glob-parent": "~5.1.0", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" + "@babel/runtime": "^7.20.13", + "@lingui/message-utils": "5.9.5" }, "engines": { - "node": ">= 8.10.0" + "node": ">=20.0.0" }, - "optionalDependencies": { - "fsevents": "~2.3.1" + "peerDependencies": { + "@lingui/babel-plugin-lingui-macro": "5.9.5", + "babel-plugin-macros": "2 || 3" + }, + "peerDependenciesMeta": { + "@lingui/babel-plugin-lingui-macro": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/@lingui/cli/node_modules/@lingui/message-utils": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/message-utils/-/message-utils-5.9.5.tgz", + "integrity": "sha512-t3dNbjb1dWkvcpXGMXIEyBDO3l4B8J2ColZXi0NTG1ioAj+sDfFxFB8fepVgd3JAk+AwARlOLvF14oS0mAdgpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@messageformat/parser": "^5.0.0", + "js-sha256": "^0.10.1" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@lingui/cli/node_modules/commander": { @@ -1206,60 +1229,14 @@ "node": ">=14" } }, - "node_modules/@lingui/cli/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@lingui/cli/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/@lingui/cli/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@lingui/cli/node_modules/readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/@lingui/conf": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.4.1.tgz", - "integrity": "sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.20.13", - "cosmiconfig": "^8.0.0", - "jest-validate": "^29.4.3", - "jiti": "^1.17.1", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=20.0.0" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/@lingui/core": { @@ -1297,14 +1274,14 @@ } }, "node_modules/@lingui/format-po": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/format-po/-/format-po-5.4.1.tgz", - "integrity": "sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==", + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/format-po/-/format-po-5.9.5.tgz", + "integrity": "sha512-abawxkaEMhAUCqxrnim2NTTeu2gd55X9tkFN8jfRM0B1LE2KjZLWCA8gSD90J/DblDwej8jK8A2BynXlcQdluQ==", "dev": true, "license": "MIT", "dependencies": { - "@lingui/conf": "5.4.1", - "@lingui/message-utils": "5.4.1", + "@lingui/conf": "5.9.5", + "@lingui/message-utils": "5.9.5", "date-fns": "^3.6.0", "pofile": "^1.1.4" }, @@ -1312,6 +1289,47 @@ "node": ">=20.0.0" } }, + "node_modules/@lingui/format-po/node_modules/@lingui/conf": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.9.5.tgz", + "integrity": "sha512-k5r9ssOZirhS5BlqdsK5L0rzlqnHeryoJHAQIpUpeh8g5ymgpbUN7L4+4C4hAX/tddAFiCFN8boHTiu6Wbt83Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "cosmiconfig": "^8.0.0", + "jest-validate": "^29.4.3", + "jiti": "^2.5.1", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@lingui/format-po/node_modules/@lingui/message-utils": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/message-utils/-/message-utils-5.9.5.tgz", + "integrity": "sha512-t3dNbjb1dWkvcpXGMXIEyBDO3l4B8J2ColZXi0NTG1ioAj+sDfFxFB8fepVgd3JAk+AwARlOLvF14oS0mAdgpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@messageformat/parser": "^5.0.0", + "js-sha256": "^0.10.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@lingui/format-po/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/@lingui/macro": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/@lingui/macro/-/macro-5.4.1.tgz", @@ -1395,20 +1413,47 @@ } }, "node_modules/@lingui/vite-plugin": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/@lingui/vite-plugin/-/vite-plugin-5.4.1.tgz", - "integrity": "sha512-4BxkHliJdGk7lmD++Bee9iy+n66kUONUPgpNqEgcuoEfaL0UgWWLbpkOr42X3tMUVt/S/SUM7firx6NexSCJ4Q==", + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/vite-plugin/-/vite-plugin-5.9.5.tgz", + "integrity": "sha512-oFlEkr4/56yWZYVJ5xfYfJUTgegSKkbQUo/t/MJVFcyxiMvtGsyzrZdm7grwbY9v8JbVyW5BdcIGN6/Z8wvEtw==", "dev": true, "license": "MIT", "dependencies": { - "@lingui/cli": "5.4.1", - "@lingui/conf": "5.4.1" + "@lingui/cli": "5.9.5", + "@lingui/conf": "5.9.5" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "vite": "^3 || ^4 || ^5.0.9 || ^6 || ^7" + "vite": "^3 || ^4 || ^5.0.9 || ^6 || ^7 || ^8" + } + }, + "node_modules/@lingui/vite-plugin/node_modules/@lingui/conf": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.9.5.tgz", + "integrity": "sha512-k5r9ssOZirhS5BlqdsK5L0rzlqnHeryoJHAQIpUpeh8g5ymgpbUN7L4+4C4hAX/tddAFiCFN8boHTiu6Wbt83Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "cosmiconfig": "^8.0.0", + "jest-validate": "^29.4.3", + "jiti": "^2.5.1", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@lingui/vite-plugin/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/@messageformat/parser": { @@ -3758,6 +3803,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3784,16 +3842,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3810,9 +3868,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "devOptional": true, "funding": [ { @@ -3830,10 +3888,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3904,9 +3963,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "devOptional": true, "funding": [ { @@ -3971,6 +4030,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", + "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.5.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.1" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -4294,9 +4375,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4386,9 +4467,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.182", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz", - "integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "devOptional": true, "license": "ISC" }, @@ -4492,6 +4573,17 @@ "node": ">=6" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -4613,6 +4705,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4813,6 +4918,19 @@ "node": ">=0.12.0" } }, + "node_modules/is-observable": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-2.1.0.tgz", + "integrity": "sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -4877,16 +4995,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, "node_modules/js-sha256": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz", @@ -4900,10 +5008,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5207,13 +5325,6 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5472,9 +5583,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -5506,11 +5617,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5531,6 +5645,13 @@ "node": ">=0.10.0" } }, + "node_modules/observable-fns": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/observable-fns/-/observable-fns-0.6.1.tgz", + "integrity": "sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg==", + "dev": true, + "license": "MIT" + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -5731,9 +5852,9 @@ "license": "MIT" }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -5751,9 +5872,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -5840,16 +5961,6 @@ "node": ">=14" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react": { "version": "19.1.2", "resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz", @@ -5992,6 +6103,32 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -6220,22 +6357,19 @@ } }, "node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -6425,9 +6559,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -6451,12 +6585,42 @@ "node": ">=18" } }, + "node_modules/threads": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/threads/-/threads-1.7.0.tgz", + "integrity": "sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0", + "debug": "^4.2.0", + "is-observable": "^2.1.0", + "observable-fns": "^0.6.1" + }, + "funding": { + "url": "https://github.com/andywer/threads.js?sponsor=1" + }, + "optionalDependencies": { + "tiny-worker": ">= 2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tiny-worker": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tiny-worker/-/tiny-worker-2.3.0.tgz", + "integrity": "sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "esm": "^3.2.25" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6487,16 +6651,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6613,9 +6767,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "devOptional": true, "funding": [ { @@ -6694,9 +6848,9 @@ "license": "MIT" }, "node_modules/valibot": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz", - "integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", + "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -6758,13 +6912,13 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -6832,6 +6986,490 @@ } } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -6842,25 +7480,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/internal/site/package.json b/internal/site/package.json index 585f56f4c..12aa80cf2 100644 --- a/internal/site/package.json +++ b/internal/site/package.json @@ -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": { diff --git a/internal/site/src/components/add-system.tsx b/internal/site/src/components/add-system.tsx index e3a8e1b0f..949d7bdd1 100644 --- a/internal/site/src/components/add-system.tsx +++ b/internal/site/src/components/add-system.tsx @@ -123,14 +123,10 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => setHostValue(system?.host ?? "") }} > - + - {system ? ( - Edit System - ) : ( - Add System - )} + {system ? Edit System : Add System} Docker @@ -139,40 +135,55 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => - {/* Docker (set tab index to prevent auto focusing content in edit system dialog) */} - - - - Copy the - docker-compose.yml content for the agent - below, or register agents automatically with a{" "} - setOpen(false)} - href={getPagePath($router, "settings", { name: "tokens" })} - className="link" - > - universal token - - . - - - - {/* Binary */} - - - - Copy the installation command for the agent below, or register agents automatically with a{" "} - setOpen(false)} - href={getPagePath($router, "settings", { name: "tokens" })} - className="link" - > - universal token - - . - - - + {/* Keep both translations in the same grid cell so tab changes do not resize the dialog. */} + +
+ {/* Docker (set tab index to prevent auto focusing content in edit system dialog) */} + +

+ + Copy the + docker-compose.yml content for the agent + below, or register agents automatically with a{" "} + setOpen(false)} + href={getPagePath($router, "settings", { name: "tokens" })} + className="link" + > + universal token + + . + +

+
+ {/* Binary */} + +

+ + Copy the installation command for the agent below, or register agents automatically with a{" "} + setOpen(false)} + href={getPagePath($router, "settings", { name: "tokens" })} + className="link" + > + universal token + + . + +

+
+
+
- {/* Docker */} - - - copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token) - } - icon={} - 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], - }, - ]} - /> - - {/* Binary */} - - } - 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], - }, - ]} - /> - +
+ {/* Docker */} +
+ + copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token) + } + icon={} + 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], + }, + ]} + /> +
+ {/* Binary */} +
+ } + 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], + }, + ]} + /> +
+
{/* Save */} - +
diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts index 0a99eaa23..c97610f15 100644 --- a/internal/site/src/components/charts/hooks.ts +++ b/internal/site/src/components/charts/hooks.ts @@ -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) diff --git a/internal/site/src/components/routes/system/storage-pools-table.tsx b/internal/site/src/components/routes/system/storage-pools-table.tsx index 0cad1d092..06d5c596b 100644 --- a/internal/site/src/components/routes/system/storage-pools-table.tsx +++ b/internal/site/src/components/routes/system/storage-pools-table.tsx @@ -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[] = [ accessorKey: "health", sortingFn: (a, b) => a.original.health.localeCompare(b.original.health), header: ({ column }) => , - cell: ({ getValue }) => { + cell: ({ getValue, row }) => { const health = (getValue() as string) || "" - return {health || t`Unknown`} + return {health || t`Unknown`} }, }, { @@ -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({ Pool Health: {health} + {pool.status && {pool.status}} {pool.scrub?.state && ( Scrub: {pool.scrub.state} diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts index c801f7631..f724cecbf 100644 --- a/internal/site/src/components/routes/system/use-system-data.ts +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -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]) diff --git a/internal/site/src/components/systems-table/systems-table-columns.tsx b/internal/site/src/components/systems-table/systems-table-columns.tsx index 94cfb6dd8..0454fa939 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -476,7 +476,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef 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" })} diff --git a/internal/site/src/lib/package-updates.test.ts b/internal/site/src/lib/package-updates.test.ts index ade1be049..191f37840 100644 --- a/internal/site/src/lib/package-updates.test.ts +++ b/internal/site/src/lib/package-updates.test.ts @@ -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") diff --git a/internal/site/src/lib/package-updates.ts b/internal/site/src/lib/package-updates.ts index 775bdfe2c..a938ebf94 100644 --- a/internal/site/src/lib/package-updates.ts +++ b/internal/site/src/lib/package-updates.ts @@ -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: "" } } diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 38febbe6f..267bda042 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -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