From bc2278e7e9820825ce990fb2fe7fca4b9fe9a1c3 Mon Sep 17 00:00:00 2001 From: Tomasz Machalski Date: Tue, 6 Oct 2026 18:10:12 +0200 Subject: [PATCH 01/27] feat(agent): Handle update package check for xbps package manager (#2522) Co-authored-by: hank --- agent/package_updates.go | 90 +++++++++++++++++++ agent/package_updates_test.go | 61 +++++++++++++ .../xbps_void_check_update.txt | 3 + .../xbps_void_install_update.txt | 7 ++ .../xbps_void_query_installed.txt | 29 ++++++ internal/site/src/lib/package-updates.test.ts | 4 +- internal/site/src/lib/package-updates.ts | 12 ++- 7 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 agent/test-data/package_updates/xbps_void_check_update.txt create mode 100644 agent/test-data/package_updates/xbps_void_install_update.txt create mode 100644 agent/test-data/package_updates/xbps_void_query_installed.txt diff --git a/agent/package_updates.go b/agent/package_updates.go index 9697f6392..a7e29b4eb 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -24,6 +24,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. @@ -154,6 +157,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 } @@ -509,3 +514,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 68d67134b..7b0131f7e 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 @@ -255,6 +315,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/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/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: "" } } From 7305eda972cb423bd51ac18f4966312b346360d0 Mon Sep 17 00:00:00 2001 From: Mario Mohar <76663003+Mario-Mohar@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:51:42 +0200 Subject: [PATCH 02/27] fix(hub): ping a slow agent so its connection survives between polls (#2435) --- internal/hub/systems/system.go | 20 +++ internal/hub/systems/ws_data_timeout_test.go | 178 +++++++++++++++++++ internal/hub/ws/ws.go | 11 +- internal/hub/ws/ws_test.go | 10 +- internal/hub/ws/ws_test_helpers.go | 13 ++ 5 files changed, 227 insertions(+), 5 deletions(-) diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 0a292668d..57d0f1995 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -174,6 +174,26 @@ func (sys *System) update() error { data, err := sys.fetchDataFromAgent(options) if err != nil { + // Keep the WebSocket alive even though this fetch failed. + // + // Only a message from the agent resets the read deadline, and an agent + // still collecting sends nothing. Without this ping the deadline expires + // between polls, the hub closes the connection, the agent reconnects, + // collects slowly again, and the cycle repeats. Pinging here is the same + // move handlePaused already makes for a paused system, and Ping() pushes + // the deadline out as it sends, so it works without an answer, which + // matters because a collecting agent cannot answer. + // + // This is why the deadline has to exceed interval + wsDataRequestTimeout: + // the ping only happens once the fetch gives up. See ws.deadline. + // + // The system is still reported down: that comes from the error we + // return. Only the connection survives, so recovery costs no reconnect. + // A failed ping needs no handling either, since a connection that + // cannot be pinged runs into its deadline anyway. + if sys.WsConn != nil && sys.WsConn.IsConnected() { + _ = sys.WsConn.Ping() + } return err } diff --git a/internal/hub/systems/ws_data_timeout_test.go b/internal/hub/systems/ws_data_timeout_test.go index ae6720fd2..a8a019c47 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,180 @@ 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. +func TestSlowAgentKeepsConnectionAcrossPolls(t *testing.T) { + tick := time.Duration(interval) * time.Millisecond / timelineScale + + tests := []struct { + name string + deadline time.Duration + wantConnected bool + }{ + {"production deadline outlasts a failed poll", ws.Deadline() / timelineScale, 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, 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 }) + + 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() + + // Two failed polls: the first shows the ping lands, the second shows + // the ping keeps re-arming the deadline. + for poll := 1; poll <= 2; 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/ws/ws.go b/internal/hub/ws/ws.go index 7d61e596c..6b268a3ba 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 { 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 +} From d91f06f3c533b231346dc1d0ca5bf0bca265e462 Mon Sep 17 00:00:00 2001 From: henrygd Date: Fri, 9 Oct 2026 11:09:43 -0400 Subject: [PATCH 03/27] fix(hub): stop keepalive pings after prolonged agent silence The keepalive ping on a failed fetch re-arms the read deadline without needing a reply, so a dead peer (power loss, partition) kept its connection until TCP gave up. Track when the agent last sent a message and only ping while that is under maxAgentSilence (3m). A live agent always answers within interval + its own 120s deadline, so slow agents are unaffected; a dead one is now released by the read deadline and falls back through the normal down/SSH path. --- internal/hub/systems/system.go | 29 ++++++++------------ internal/hub/systems/ws_data_timeout_test.go | 21 ++++++++++---- internal/hub/ws/ws.go | 8 ++++++ 3 files changed, 34 insertions(+), 24 deletions(-) diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 57d0f1995..7ef9e3223 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -174,24 +174,11 @@ func (sys *System) update() error { data, err := sys.fetchDataFromAgent(options) if err != nil { - // Keep the WebSocket alive even though this fetch failed. - // - // Only a message from the agent resets the read deadline, and an agent - // still collecting sends nothing. Without this ping the deadline expires - // between polls, the hub closes the connection, the agent reconnects, - // collects slowly again, and the cycle repeats. Pinging here is the same - // move handlePaused already makes for a paused system, and Ping() pushes - // the deadline out as it sends, so it works without an answer, which - // matters because a collecting agent cannot answer. - // - // This is why the deadline has to exceed interval + wsDataRequestTimeout: - // the ping only happens once the fetch gives up. See ws.deadline. - // - // The system is still reported down: that comes from the error we - // return. Only the connection survives, so recovery costs no reconnect. - // A failed ping needs no handling either, since a connection that - // cannot be pinged runs into its deadline anyway. - if sys.WsConn != nil && sys.WsConn.IsConnected() { + // 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 @@ -820,6 +807,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/ws_data_timeout_test.go b/internal/hub/systems/ws_data_timeout_test.go index a8a019c47..c50cd4632 100644 --- a/internal/hub/systems/ws_data_timeout_test.go +++ b/internal/hub/systems/ws_data_timeout_test.go @@ -185,18 +185,26 @@ const timelineScale = 100 // 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. +// 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 }{ - {"production deadline outlasts a failed poll", ws.Deadline() / timelineScale, true}, + // 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, false}, + {"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 { @@ -206,6 +214,9 @@ func TestSlowAgentKeepsConnectionAcrossPolls(t *testing.T) { 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 @@ -244,9 +255,7 @@ func TestSlowAgentKeepsConnectionAcrossPolls(t *testing.T) { require.NoError(t, err, "the first poll must succeed") pollZero := time.Now() - // Two failed polls: the first shows the ping lands, the second shows - // the ping keeps re-arming the deadline. - for poll := 1; poll <= 2; poll++ { + for poll := 1; poll <= tc.polls; poll++ { time.Sleep(time.Until(pollZero.Add(time.Duration(poll) * tick))) if !sys.WsConn.IsConnected() { break diff --git a/internal/hub/ws/ws.go b/internal/hub/ws/ws.go index 6b268a3ba..c040c8190 100644 --- a/internal/hub/ws/ws.go +++ b/internal/hub/ws/ws.go @@ -36,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 @@ -66,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 } @@ -85,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) } @@ -126,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 { From 195afc10b8924ef4d6afc5d409eb8a8e1c43b96c Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Fri, 9 Oct 2026 17:17:25 +0200 Subject: [PATCH 04/27] deps(site): bump npm packages with security alerts (#2575) --- internal/site/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": { From e30e980854408d46b2e8837a14a0c0d771a18545 Mon Sep 17 00:00:00 2001 From: henrygd Date: Fri, 9 Oct 2026 11:19:45 -0400 Subject: [PATCH 05/27] deps: update npm / bun lockfiles --- internal/site/bun.lock | 62 +- internal/site/package-lock.json | 1289 +++++++++++++++++++++++-------- 2 files changed, 1012 insertions(+), 339 deletions(-) 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", From 5529d2e15e142dcec0ee5a239ffadb33e3295b01 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Fri, 9 Oct 2026 17:39:09 +0200 Subject: [PATCH 06/27] fix(agent): verify systemd on dbus before starting service monitoring (#2573) --- agent/systemd.go | 7 +++++++ agent/systemd_test.go | 13 ++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/agent/systemd.go b/agent/systemd.go index d731ded84..7b3c29647 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(), 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") From 5714643ff9001e70e0fbf8ecb4f55369dcf7ae61 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Fri, 9 Oct 2026 18:10:46 +0200 Subject: [PATCH 07/27] fix(site): keep last known update count in systems table when host is down (#2559) --- .../src/components/systems-table/systems-table-columns.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" })} From b11e3617990bee170122c4af1913ed79ddb4a632 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Fri, 9 Oct 2026 18:58:05 +0200 Subject: [PATCH 08/27] fix(agent): prevent network counter underflow on interface removal and container restart (#2574) Co-authored-by: hank --- agent/docker.go | 16 +++++++-- agent/docker_test.go | 58 +++++++++++++++++++++++++++++++++ agent/network.go | 28 +++++----------- agent/network_test.go | 76 ++++++++++++++++++++----------------------- 4 files changed, 115 insertions(+), 63 deletions(-) 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/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"]) } From 017ebb091aa64596f9545c89e67f6c880f8bb121 Mon Sep 17 00:00:00 2001 From: Santhi Prakash <38608178+santhiprakash@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:27:40 +0530 Subject: [PATCH 09/27] feat: surface zpool status messages in pool health (#2509) Co-authored-by: henrygd --- agent/storage_pool.go | 2 ++ agent/storage_pool_test.go | 4 +++- agent/test-data/zfs/zpool_status.txt | 18 ++++++++++++++ agent/zfs/zfs_status.go | 24 +++++++++++++++---- agent/zfs/zfs_test.go | 15 +++++++++++- internal/entities/zfs/zfs.go | 1 + internal/hub/systems/system_zfs.go | 1 + internal/hub/systems/system_zfs_test.go | 3 ++- .../migrations/1790924727_zfs_pool_status.go | 24 +++++++++++++++++++ .../routes/system/storage-pools-table.tsx | 21 ++++++++++------ internal/site/src/types.d.ts | 2 ++ 11 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 internal/migrations/1790924727_zfs_pool_status.go 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/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_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..db0fbd467 100644 --- a/agent/zfs/zfs_test.go +++ b/agent/zfs/zfs_test.go @@ -73,11 +73,12 @@ func TestParseZpoolStatusOutput(t *testing.T) { 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 +91,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 +99,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/entities/zfs/zfs.go b/internal/entities/zfs/zfs.go index 99aea84a8..a3d9adea5 100644 --- a/internal/entities/zfs/zfs.go +++ b/internal/entities/zfs/zfs.go @@ -36,6 +36,7 @@ type PoolDetail struct { Raw bool `json:"raw,omitempty"` Name string `json:"name"` Health string `json:"health,omitempty"` + Status string `json:"status,omitempty"` Size uint64 `json:"size,omitempty"` // bytes Alloc uint64 `json:"alloc,omitempty"` // bytes Free uint64 `json:"free,omitempty"` // bytes 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/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/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/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 From d8b41919751da63f63745b4a9d2ce894dd61342d Mon Sep 17 00:00:00 2001 From: Fuwuwuwu <72070458+floatwoozy@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:56:46 +0800 Subject: [PATCH 10/27] fix(agent): reset host CPU baseline after counter rollback --- agent/cpu.go | 15 +++++++++---- agent/cpu_test.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) 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) +} From 13bdd05934a7363016aa9a92c0d5bef13c0e81a4 Mon Sep 17 00:00:00 2001 From: spatiumstas <79056064+spatiumstas@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:55:06 +0300 Subject: [PATCH 11/27] fix: handle cached NVMe failures and empty SMART responses --- agent/smart.go | 4 +-- agent/smart_test.go | 42 ++++++++++++++++++++++++ internal/common/common-ws.go | 2 +- internal/hub/transport/transport_test.go | 13 ++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/agent/smart.go b/agent/smart.go index 3490e7fb0..4dc1497f4 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -513,8 +513,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 diff --git a/agent/smart_test.go b/agent/smart_test.go index 8b3071c8e..61860a725 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "github.com/henrygd/beszel/internal/entities/smart" @@ -1483,3 +1484,44 @@ 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 isNvmeControllerPath(os.Args[len(os.Args)-1]) { + 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.True(t, sm.isExcludedDevice("/dev/nvme0")) + }) + } +} 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/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) +} From 383e9c707249e88075044d2170a0f1e68411ee76 Mon Sep 17 00:00:00 2001 From: Bharat Kaurav <117659673+TheNetherWatcher@users.noreply.github.com> Date: Sun, 11 Oct 2026 18:47:17 +0530 Subject: [PATCH 12/27] fix(agent): widen NVMe unsafe_shutdowns to uint (#2583) unsafe_shutdowns was uint16, so drives reporting more than 65535 unsafe shutdowns made json.Unmarshal fail and the entire NVMe SMART record was discarded with only "no valid SMART data found" in the logs. Widen it to uint, matching power_cycles (#2484), and log the unmarshal error at debug level so similar failures are diagnosable. Fixes #2582 --- agent/smart.go | 1 + agent/smart_test.go | 24 ++++++++++++++++++++++++ internal/entities/smart/smart.go | 2 +- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/agent/smart.go b/agent/smart.go index 4dc1497f4..0834d19e4 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -1138,6 +1138,7 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo data := &smart.SmartInfoForNvme{} if err := json.Unmarshal(output, &data); err != nil { + slog.Debug("failed to parse NVMe smartctl output", "err", err) return false, 0 } diff --git a/agent/smart_test.go b/agent/smart_test.go index 61860a725..f403abb7f 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "testing" @@ -292,6 +293,29 @@ func TestParseSmartForNvme(t *testing.T) { } } +func TestParseSmartForNvmeLargeUnsafeShutdowns(t *testing.T) { + fixturePath := filepath.Join("test-data", "smart", "nvme0.json") + data, err := os.ReadFile(fixturePath) + require.NoError(t, err) + + // Some drives report counts that overflow uint16, which previously made the + // whole record fail to unmarshal and get discarded. + re := regexp.MustCompile(`"unsafe_shutdowns":\s*\d+`) + require.True(t, re.Match(data), "fixture should contain unsafe_shutdowns") + data = re.ReplaceAll(data, []byte(`"unsafe_shutdowns": 159754`)) + + sm := &SmartManager{ + SmartDataMap: make(map[string]*smart.SmartData), + } + + hasData, _ := sm.parseSmartForNvme(data, "") + require.True(t, hasData) + + deviceData, ok := sm.SmartDataMap["2024031600129"] + require.True(t, ok, "expected smart data entry for serial 2024031600129") + assertAttrValue(t, deviceData.Attributes, "UnsafeShutdowns", 159754) +} + func TestHasDataForDevice(t *testing.T) { sm := &SmartManager{ SmartDataMap: map[string]*smart.SmartData{ diff --git a/internal/entities/smart/smart.go b/internal/entities/smart/smart.go index 694522e59..55aee622f 100644 --- a/internal/entities/smart/smart.go +++ b/internal/entities/smart/smart.go @@ -477,7 +477,7 @@ type NVMeSmartHealthInformationLog struct { ControllerBusyTime uint `json:"controller_busy_time"` PowerCycles uint `json:"power_cycles"` PowerOnHours uint32 `json:"power_on_hours"` - UnsafeShutdowns uint16 `json:"unsafe_shutdowns"` + UnsafeShutdowns uint `json:"unsafe_shutdowns"` MediaErrors uint `json:"media_errors"` NumErrLogEntries uint `json:"num_err_log_entries"` WarningTempTime uint `json:"warning_temp_time"` From f8d3fe1817b2ac977c051243d6ffc9077a000ba3 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sun, 11 Oct 2026 09:47:30 -0400 Subject: [PATCH 13/27] fix: use omitzero for scalar JSON fields under json v2 PocketBase now marshals record JSON fields with encoding/json/v2, where omitempty no longer omits zero numbers or false. This roughly doubled the size of stored system_stats rows with zero-value keys. Switch scalar fields to omitzero to restore the v1 output shape, and add a test that compares v1 and v2 output for zero-heavy values. --- internal/alerts/alerts.go | 2 +- internal/entities/container/container.go | 12 ++--- internal/entities/smart/smart.go | 12 ++--- internal/entities/system/package_updates.go | 6 +-- internal/entities/system/system.go | 56 ++++++++++----------- internal/entities/system/system_test.go | 31 ++++++++++++ internal/entities/zfs/zfs.go | 22 ++++---- internal/hub/server.go | 2 +- 8 files changed, 87 insertions(+), 56 deletions(-) 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/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 55aee622f..7fa94b8c9 100644 --- a/internal/entities/smart/smart.go +++ b/internal/entities/smart/smart.go @@ -523,11 +523,11 @@ type SmartData struct { 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 +539,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..640f680d3 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" ) @@ -117,3 +119,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 a3d9adea5..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,13 +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"` Status string `json:"status,omitempty"` - Size uint64 `json:"size,omitempty"` // bytes - Alloc uint64 `json:"alloc,omitempty"` // bytes - Free uint64 `json:"free,omitempty"` // bytes + 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"` @@ -49,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 From 7f63e65f22bff5d4bf5962e3555bc4107c31de6e Mon Sep 17 00:00:00 2001 From: yi111 <153097222+Yi-111-a@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:35:56 +0800 Subject: [PATCH 14/27] fix(agent): parse intel_gpu_top -J arrays that omit commas between samples (#2589) --- agent/gpu_intel.go | 110 +++++++++++++++++++++++++++++++++++---------- agent/gpu_test.go | 29 +++++++++++- 2 files changed, 115 insertions(+), 24 deletions(-) diff --git a/agent/gpu_intel.go b/agent/gpu_intel.go index b0813ffa1..1b37da6f2 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,34 @@ 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() { + for { + obj, err := nextIntelJSONObject(br) + 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 + } var sample intelGpuJSONSample - if err := dec.Decode(&sample); err != nil { + if err := json.Unmarshal(obj, &sample); err != nil { if er.eof { break } @@ -147,6 +145,72 @@ 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). +func nextIntelJSONObject(br *bufio.Reader) ([]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) + default: + return nil, fmt.Errorf("invalid character %q looking for beginning of intel_gpu_top sample", b) + } + } +} + +// readJSONObject reads one JSON object from br, respecting strings and nesting. +func readJSONObject(br *bufio.Reader) ([]byte, error) { + var buf []byte + 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_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`), From 7f2eb90f24df180a7c0dd6c1b888aaba65b3dbb6 Mon Sep 17 00:00:00 2001 From: henrygd Date: Sun, 11 Oct 2026 10:44:18 -0400 Subject: [PATCH 15/27] perf(agent): reuse buffer when reading intel_gpu_top samples --- agent/gpu_intel.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/agent/gpu_intel.go b/agent/gpu_intel.go index 1b37da6f2..43257689c 100644 --- a/agent/gpu_intel.go +++ b/agent/gpu_intel.go @@ -105,8 +105,10 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error { var hadDataRow bool // skip first data row because it sometimes has erroneous data var skippedFirstDataRow bool + // buf is reused across samples; json.Unmarshal does not retain its input. + var buf []byte for { - obj, err := nextIntelJSONObject(br) + obj, err := nextIntelJSONObject(br, buf) if err != nil { if err == io.EOF { break @@ -120,6 +122,7 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error { if obj == nil { // closing "]" break } + buf = obj var sample intelGpuJSONSample if err := json.Unmarshal(obj, &sample); err != nil { if er.eof { @@ -147,8 +150,9 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error { // 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). -func nextIntelJSONObject(br *bufio.Reader) ([]byte, error) { +// (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 { @@ -162,16 +166,16 @@ func nextIntelJSONObject(br *bufio.Reader) ([]byte, error) { _, _ = br.ReadByte() return nil, nil case '{': - return readJSONObject(br) + return readJSONObject(br, buf[:0]) default: return nil, fmt.Errorf("invalid character %q looking for beginning of intel_gpu_top sample", b) } } } -// readJSONObject reads one JSON object from br, respecting strings and nesting. -func readJSONObject(br *bufio.Reader) ([]byte, error) { - var buf []byte +// 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 From d324ccc0977d0347e4f94529490fefbc72e54900 Mon Sep 17 00:00:00 2001 From: HTTP/2 410 Gone <24599209+Mr-Sheep@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:22:37 -0700 Subject: [PATCH 16/27] feat(agent): support custom WebSocket headers with `HUB_HEADERS` (#2585) Co-authored-by: henrygd --- agent/client.go | 51 +++++++++++++++ agent/client_test.go | 124 ++++++++++++++++++++++++++++++++++++ agent/connection_manager.go | 2 +- 3 files changed, 176 insertions(+), 1 deletion(-) 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") From 4da7d867f237542aadac148eebbcff60f82d9ef9 Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Sun, 11 Oct 2026 19:02:38 +0200 Subject: [PATCH 17/27] systemd: include user journals in service logs (#2553) --- agent/systemd.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent/systemd.go b/agent/systemd.go index 7b3c29647..d24d77910 100644 --- a/agent/systemd.go +++ b/agent/systemd.go @@ -374,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} From 596f96442e5b60706018e47d63c56bd5abad2a37 Mon Sep 17 00:00:00 2001 From: Mario Mohar <76663003+Mario-Mohar@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:39:00 +0200 Subject: [PATCH 18/27] fix(hub): keep the updater running when the system record lookup fails (#2588) getRecord removed the system from the manager on any error, so a query that timed out while the hub was stalled stopped the updater for good. The system stayed 'up' in the database and was never polled again until a restart. Only a record that is really gone removes the system now. (#2544) --- internal/hub/systems/system.go | 5 ++ .../hub/systems/system_get_record_test.go | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/hub/systems/system_get_record_test.go diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 7ef9e3223..d03d2bccb 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -618,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 { 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") +} From da51dfd5986528d7341ba488b96046603298ae0b Mon Sep 17 00:00:00 2001 From: Mario Mohar <76663003+Mario-Mohar@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:14:31 +0200 Subject: [PATCH 19/27] fix(site): unsubscribe live metrics that resolve after leaving 1m (#2599) pb.realtime.subscribe() resolves only once the server has confirmed the subscription. When the chart time changed away from 1m before that, the effect cleanup ran with the initial no-op, the subscription stayed active, and its 1m points kept overwriting the 1h history. Track cancellation, unsubscribe as soon as the late subscription resolves, and ignore messages that arrive after cleanup. Fixes #2598. --- .../src/components/routes/system/use-system-data.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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]) From 3d33d5eabb2bed36b5ff252057a96647dbd8c8c7 Mon Sep 17 00:00:00 2001 From: spatiumstas <79056064+spatiumstas@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:28:44 +0300 Subject: [PATCH 20/27] fix(agent): preserve NVMe namespace fallbacks across rescans (#2597) --- agent/smart.go | 66 ++++++++++++++++++++----- agent/smart_test.go | 116 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 14 deletions(-) diff --git a/agent/smart.go b/agent/smart.go index 0834d19e4..fb7e97776 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 @@ -216,6 +217,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 +234,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 == "" { @@ -535,24 +571,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 +840,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 +850,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) diff --git a/agent/smart_test.go b/agent/smart_test.go index f403abb7f..7bbc5673d 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -3,6 +3,7 @@ package agent import ( + "encoding/json" "errors" "fmt" "os" @@ -10,6 +11,7 @@ import ( "regexp" "strings" "testing" + "time" "github.com/henrygd/beszel/internal/entities/smart" "github.com/stretchr/testify/assert" @@ -1516,7 +1518,15 @@ func init() { if fixture == "" { return } - if isNvmeControllerPath(os.Args[len(os.Args)-1]) { + 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) } @@ -1545,7 +1555,109 @@ func TestCollectSmartNvmeNamespaceFallback(t *testing.T) { require.NoError(t, sm.CollectSmart(device)) assert.Equal(t, "/dev/nvme0n1", device.Name) assert.Equal(t, "/dev/nvme0n1", sm.SmartDataMap["2024031600129"].DiskName) - assert.True(t, sm.isExcludedDevice("/dev/nvme0")) + 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.SmartInfoForNvme + 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, 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) }) } } From 665bcb3e53d80a4d4c01e43e0b0bea0ecc85383c Mon Sep 17 00:00:00 2001 From: Sven van Ginkel Date: Mon, 12 Oct 2026 16:34:37 +0200 Subject: [PATCH 21/27] refactor(agent): consolidate smartctl JSON types and SMART parsers (#2584) --- agent/smart.go | 339 +++++++++-------------- agent/smart_test.go | 194 +++++++------ internal/entities/smart/smart.go | 454 ++++++------------------------- 3 files changed, 306 insertions(+), 681 deletions(-) diff --git a/agent/smart.go b/agent/smart.go index fb7e97776..57dae3a8a 100644 --- a/agent/smart.go +++ b/agent/smart.go @@ -108,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() @@ -535,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) @@ -877,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 { @@ -1009,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 @@ -1037,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 { @@ -1177,22 +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 { - slog.Debug("failed to parse NVMe smartctl output", "err", err) - 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 @@ -1202,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 7bbc5673d..c7b7ee960 100644 --- a/agent/smart_test.go +++ b/agent/smart_test.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" "testing" "time" @@ -60,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) @@ -295,27 +299,79 @@ func TestParseSmartForNvme(t *testing.T) { } } -func TestParseSmartForNvmeLargeUnsafeShutdowns(t *testing.T) { - fixturePath := filepath.Join("test-data", "smart", "nvme0.json") - data, err := os.ReadFile(fixturePath) - require.NoError(t, err) +// 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 + } + }`) - // Some drives report counts that overflow uint16, which previously made the - // whole record fail to unmarshal and get discarded. - re := regexp.MustCompile(`"unsafe_shutdowns":\s*\d+`) - require.True(t, re.Match(data), "fixture should contain unsafe_shutdowns") - data = re.ReplaceAll(data, []byte(`"unsafe_shutdowns": 159754`)) - - sm := &SmartManager{ - SmartDataMap: make(map[string]*smart.SmartData), - } - - hasData, _ := sm.parseSmartForNvme(data, "") + sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} + hasData, _ := sm.parseSmartForNvme(jsonPayload, "") require.True(t, hasData) - deviceData, ok := sm.SmartDataMap["2024031600129"] - require.True(t, ok, "expected smart data entry for serial 2024031600129") + 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) { @@ -943,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)) }) } } @@ -1039,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 @@ -1067,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, @@ -1114,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, @@ -1579,7 +1573,7 @@ func TestRefreshNvmeNamespaceFallbackSurvivesRescan(t *testing.T) { // rather than only preserving the previous cached data. fixtureData, err := os.ReadFile(fixture) require.NoError(t, err) - var updatedData smart.SmartInfoForNvme + var updatedData smart.SmartctlOutput require.NoError(t, json.Unmarshal(fixtureData, &updatedData)) require.NotNil(t, updatedData.NVMeSmartHealthInformationLog) updatedData.NVMeSmartHealthInformationLog.Temperature++ @@ -1598,7 +1592,7 @@ func TestRefreshNvmeNamespaceFallbackSurvivesRescan(t *testing.T) { 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, updatedData.NVMeSmartHealthInformationLog.Temperature, sm.SmartDataMap["2024031600129"].Temperature) + 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) diff --git a/internal/entities/smart/smart.go b/internal/entities/smart/smart.go index 7fa94b8c9..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,244 +242,6 @@ 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 uint `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"` From a1cbdc980e05f0a03616d89e0b3d6986e0e59aa8 Mon Sep 17 00:00:00 2001 From: Rajeev Gadgil Date: Mon, 12 Oct 2026 20:38:53 +0530 Subject: [PATCH 22/27] fix(hub): omit zero GPU package power from stored stats (#2551) --- internal/entities/system/system_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/entities/system/system_test.go b/internal/entities/system/system_test.go index 640f680d3..d8addf71a 100644 --- a/internal/entities/system/system_test.go +++ b/internal/entities/system/system_test.go @@ -38,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}, From a1d86d120fa046eabdfc0fc1a674e34aebdc81f1 Mon Sep 17 00:00:00 2001 From: Marrrrrrrrry <34876935+Marrrrrrrrry@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:41:10 +0800 Subject: [PATCH 23/27] refactor(ui): measure y-axis labels via textContent instead of innerHTML (#2536) --- internal/site/src/components/charts/hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 02f24ed0af859dd9a3e21ba009ed3714733a50f8 Mon Sep 17 00:00:00 2001 From: Rajeev Gadgil Date: Mon, 12 Oct 2026 22:15:27 +0530 Subject: [PATCH 24/27] fix(agent): fall back to NVML power samples when power usage is unsupported (#2550) --- agent/gpu_nvml.go | 91 +++++++++++++++++++++++++++++++-- agent/gpu_nvml_sample_test.go | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 agent/gpu_nvml_sample_test.go 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) + } +} From 9cf75cca7af6a198c418d99a2bd6d78efb2e5d9b Mon Sep 17 00:00:00 2001 From: spatiumstas <79056064+spatiumstas@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:53:11 +0300 Subject: [PATCH 25/27] fix(site): stabilize system dialog layout across tabs (#2571) --- internal/site/src/components/add-system.tsx | 205 ++++++++++---------- 1 file changed, 107 insertions(+), 98 deletions(-) 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 */} - +
From 32d469285382521bae4ad3f368a7045f32ff2f5e Mon Sep 17 00:00:00 2001 From: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:14:19 +0700 Subject: [PATCH 26/27] fix(agent): report ZFS pool capacity from the root dataset (#2540) The pool overview used `zpool list`, whose size and alloc count raw vdev space. On raidz that includes parity, so a TrueNAS pool with 65 TB usable showed 98 TB capacity, while the details page (root dataset) matched TrueNAS. Take Size, Alloc and Free from the pool's root dataset (used+avail), the same way Btrfs reports effective capacity. If the root dataset cannot be read, keep the raw figures and set Raw so the UI labels them as raw usage. Fixes #2539 --- agent/zfs/zfs.go | 34 +++++++++++++++++++++++-- agent/zfs/zfs_linux_test.go | 51 +++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go index 0cd224d6b..2806f82a0 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,36 @@ 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) { + roots := make(map[string]Dataset) + if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"); err == nil { + if datasets, err := parseZfsListOutput(out); err == nil { + for _, ds := range datasets { + roots[ds.Name] = ds + } + } + } + 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 + } } // 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..c218e1180 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,55 @@ 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,mountpoint"}, args) + return []byte("tank\t40\t60\t/tank\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\t/other\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) + }) + } } From bb23f99b3eeaced9608e11e280e7c986dba650be Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 12 Oct 2026 14:17:59 -0400 Subject: [PATCH 27/27] fix(agent): keep raw ZFS pool capacity when the root dataset has a refquota A refquota on a pool's root dataset caps its avail by the root's own referenced data, so used+avail can understate pool capacity when child datasets hold most of the data and trigger false disk usage alerts. Fetch refquota with the root datasets and fall back to the raw zpool figures for those pools. --- agent/zfs/zfs.go | 36 +++++++++++++++++++++++++++------- agent/zfs/zfs_linux_test.go | 8 +++++--- agent/zfs/zfs_nonlinux_test.go | 6 +++++- agent/zfs/zfs_test.go | 14 +++++++++++++ 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go index 2806f82a0..011a0804a 100644 --- a/agent/zfs/zfs.go +++ b/agent/zfs/zfs.go @@ -96,13 +96,9 @@ func PoolStats() ([]PoolStat, error) { // 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) { - roots := make(map[string]Dataset) - if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"); err == nil { - if datasets, err := parseZfsListOutput(out); err == nil { - for _, ds := range datasets { - roots[ds.Name] = ds - } - } + 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] @@ -114,6 +110,32 @@ func applyRootDatasetCapacity(pools []PoolStat) { } } +// 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 // information using `zfs list` (recursive by default). func Datasets() ([]Dataset, error) { diff --git a/agent/zfs/zfs_linux_test.go b/agent/zfs/zfs_linux_test.go index c218e1180..96a97cca3 100644 --- a/agent/zfs/zfs_linux_test.go +++ b/agent/zfs/zfs_linux_test.go @@ -268,8 +268,8 @@ func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) { // 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,mountpoint"}, args) - return []byte("tank\t40\t60\t/tank\n"), nil + 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 @@ -295,7 +295,9 @@ func TestPoolStatsKeepsRawCapacityWithoutRootDataset(t *testing.T) { zfsErr error }{ {"zfs list fails", nil, errors.New("zfs list failed")}, - {"root dataset missing", []byte("other\t1\t1\t/other\n"), nil}, + {"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 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_test.go b/agent/zfs/zfs_test.go index db0fbd467..f1a524c17 100644 --- a/agent/zfs/zfs_test.go +++ b/agent/zfs/zfs_test.go @@ -67,6 +67,20 @@ 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)