Compare commits

...

54 Commits

Author SHA1 Message Date
mhlas7
b4e1f3fafe fix(install): detect rc.conf.d override that blocks FreeBSD service start and restart (#2219)
* fix(install): detect rc.conf.d override that blocks FreeBSD service start

* fix log file removal
2026-08-15 16:16:33 -04:00
Maxim Lepekha
eebcd56462 feat: fan RPM monitoring (#2032)
Adds fan RPM monitoring as a peer to the existing temperature collection, addressing #1918.

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 15:59:07 -04:00
Sven van Ginkel
eb5dd230cf fix(hub): remove stale smart_devices records when a drive is no longer reported (#2178)
* Fix duplicate /dev/sdg-style entries

* only prune devices after complete refreshes

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 13:40:30 -04:00
henrygd
3337dff64b ui: possible fix for android bitwarden autofill (#1011) 2026-08-15 12:18:26 -04:00
Sven van Ginkel
b68acea5a8 fix(agent): show all IP:port mappings for containers bound to multiple IPs (#1993)
* feat: support multiple docker ips

* deduplicate wildcard docker bindings

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-08-15 11:50:20 -04:00
henrygd
66ac62a125 vulncheck: update actions/setup-go to use go-version: stable
https://github.com/actions/setup-go/blob/main/docs/advanced-usage.md#using-the-go-version-input
2026-08-14 19:44:11 -04:00
henrygd
e2a18ec636 deps: update go version to fix vulncheck workflow 2026-08-14 19:37:08 -04:00
henrygd
ffd4fc2c45 test(ghupdate): align missing checksum digest error expectation 2026-08-14 19:34:42 -04:00
henrygd
052489cada ghupdate: add checksum verification and extraction path containment guard 2026-08-14 19:23:08 -04:00
Yvan Wang
d50c09176f fix(agent): honor explicit SMART_DEVICES type hint instead of scan-detected type (#2102)
When SMART_DEVICES specifies an explicit type (e.g. /dev/sda:scsi), the
agent resolved the device type correctly but smartctlArgs dropped the -d
flag for scsi/ata (the #1345 scan-misdetection workaround), so smartctl
re-detected the wrong type (sat) and collection failed on USB drives
whose bridge does not support SAT passthrough.

Mark types that come from an explicit SMART_DEVICES hint and always pass
them through via -d, while still letting scan-detected scsi/ata
auto-detect as before. Adds regression tests for the arg building, the
full parse -> merge -> args path, and flag preservation across rescans.

Fixes #2072
2026-08-14 14:13:33 -04:00
mhlas7
fe84cfa16d Implement OPNsense boot hook for Beszel Agent (#2089)
Add OPNsense boot hook for Beszel Agent startup
2026-08-14 13:21:15 -04:00
Lars Lehtonen
6d0b83f6de fix(cron): log CreateLongerRecords errors (#1926)
chore(cron): comment cron tasks with pocketbase warning
2026-08-14 10:40:28 -04:00
Vito Cappello
cf90249519 Clamp Linux battery capacity percentage (#1957) 2026-08-14 10:17:38 -04:00
Sven van Ginkel
6607d4c0d6 fix(hub): check X-Real-IP header to resolve agent's real source IP (#1973) 2026-08-14 10:13:26 -04:00
henrygd
bc55e249c4 ui: visually truncate public key on tokens settings page 2026-08-14 10:06:24 -04:00
Sven van Ginkel
977826e8f3 feat(hub): show public key in universal token settings (#2028) 2026-08-14 10:04:40 -04:00
henrygd
8450b40e0c fix: tests that were not compatible w/ new pocketbase version 2026-08-14 00:20:45 -04:00
henrygd
ac4436bea3 fix(tests): align error message after merging #2011 2026-08-13 19:27:33 -04:00
Sven van Ginkel
fa5cda83c2 fix(agent): validate HUB_URL host to prevent misleading "lookup api" DNS error (#2011) 2026-08-13 19:25:12 -04:00
Sven van Ginkel
35af36fbd2 bug: fix docker memory chart (#2027) 2026-08-13 19:21:40 -04:00
Sven van Ginkel
e380ab6917 fix(ui): prevent usage bars from overflowing card border in grid view (#2064) 2026-08-13 19:18:43 -04:00
TowyTowy
c3a432101b fix(hub): re-dial SSH-pull agents when a connection silently dies (#2126)
The per-system updater reused an SSH client across ticks and ran the data
exchange with no deadline. If a connection went half-open (dead peer that
never sends RST/FIN) or an agent accepted the session but never wrote a
response, the read in fetchDataViaSSH blocked forever. Because
StartUpdater calls update() synchronously on its ticker, a blocked read
froze the whole per-system goroutine: the ticker's subsequent ticks were
dropped, no error was returned so the system stayed "up", and the agent
was never re-dialed until the hub process restarted.

Bound each SSH data exchange with sshOperationTimeout via runWithTimeout:
on timeout the connection is torn down (unwinding the blocked read) and a
retryable error is returned, so the next tick re-dials. Also enable TCP
keep-alive on dialed connections as a backstop for genuine network death.

Fixes #2041
2026-08-13 19:04:38 -04:00
Ryan Chou
98e86b4c9c fix(agent): treat a backwards container CPU counter as a new baseline (#2205)
CalculateCpuPercentLinux subtracted the stored previous counters from the
current ones without checking direction. When a stats response is processed
after a newer one for the same container, or an accounting counter resets,
the current total reads lower and the unsigned subtraction wraps to ~2^64
instead of going negative.

On the container counter that surfaces as the reported error, and the sample
is discarded along with the container's network stats:

    cpu pct greater than 100: 1.15292150348562e+13

On the system counter it is quieter and worse: the wrapped value inflates the
divisor, so the percentage collapses toward zero and is stored as a healthy
sample rather than rejected. A synthetic rollback measures 2.7e-12 percent.

Both directions are now treated as a new baseline (0% for one sample), which
matches how the function already handles the first-run case.

CalculateCpuPercentWindows had the same unguarded subtraction and is fixed
the same way.

Fixes #2149

Co-authored-by: Ryan Chou <ryanchou1994@users.noreply.github.com>
2026-08-13 18:14:30 -04:00
henrygd
d40372842b fix(agent): guard against unsigned underflow in memory calculations (closes #1978)
Extract host memory derivation into calculateHostMemoryUsage and use
saturating subtraction to prevent uint64 underflow caused by race
conditions when reading /proc/meminfo. Also ensure UsedPercent guards
against division by zero.

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
2026-08-13 17:06:10 -04:00
Rajkumar
c9b6279e61 feat(agent): add arm64 to beszel-agent-nvidia platform matrix (#2207) 2026-08-11 18:10:36 -04:00
Sven van Ginkel
9887b662ad fix(hub): refresh system details on agent reconnect ssh (#2086) 2026-08-11 18:08:18 -04:00
Kobi Hikri
71bc6f9b9f ci: attach provenance and SBOM attestations to the published images (#2159)
* ci: attach provenance and SBOM attestations to the published images

* ci: restore trailing newline at end of file
2026-08-11 18:04:39 -04:00
dependabot[bot]
87468965dc chore(deps): bump docker/metadata-action from 5 to 6 (#2201)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:56:29 -04:00
dependabot[bot]
db0da58ac8 chore(deps): bump docker/login-action from 3 to 4 (#2202)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:49:49 -04:00
dependabot[bot]
b9b3a23063 chore(deps): bump actions/setup-dotnet from 4 to 6 (#2203)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:49:28 -04:00
dependabot[bot]
ac34e58113 chore(deps): bump actions/checkout from 4 to 7 (#2200)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:48:24 -04:00
dependabot[bot]
3c8703d1c0 chore(deps): bump actions/stale from 10 to 11 (#2199)
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:48:11 -04:00
Sven van Ginkel
01efba50a5 fix(agent): remove unsynchronized hubVersions cache causing concurrent map write panic (#2153) 2026-08-11 17:14:59 -04:00
henrygd
d0453f1ca6 update go version and packages 2026-08-11 16:53:23 -04:00
Quadrubo
7ffc6e81ce fix(agent): prevent a malformed Docker response from permanently breaking container stats (#2066)
decode() reused a single json.Decoder across responses. Once a decode errored on a truncated or malformed body (e.g. a container removed mid-request), the decoder stayed desynced and every subsequent decode failed, silently stopping all container stats until the agent was restarted. Decode the buffered bytes with json.Unmarshal instead, which keeps the reusable read buffer but holds no cross-call state.

Regression from d67d638.
2026-08-11 16:40:24 -04:00
Pavel Artsishevsky
1aa9fcd31d Fixes redirect-based OAuth login when OAUTH_DISABLE_POPUP=true is set (#2171)
Switch to localStorage, which is shared across all tabs/windows of the
same origin. Also clean up the stored entry and strip the callback URL
on both success and failure paths.
2026-08-11 16:33:05 -04:00
Vito Cappello
bd52134558 fix(agent): don't warn on mdraid consistency checks (#2067) 2026-08-11 15:50:58 -04:00
Anikesh Thakur
3fac02f0c0 fix: start agent after pfSense reboot (#2214) 2026-08-11 15:44:27 -04:00
dependabot[bot]
e4c0522cae chore(deps): bump github.com/ebitengine/purego from 0.10.0 to 0.10.2 (#2191)
Bumps [github.com/ebitengine/purego](https://github.com/ebitengine/purego) from 0.10.0 to 0.10.2.
- [Release notes](https://github.com/ebitengine/purego/releases)
- [Commits](https://github.com/ebitengine/purego/compare/v0.10.0...v0.10.2)

---
updated-dependencies:
- dependency-name: github.com/ebitengine/purego
  dependency-version: 0.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:30:38 -04:00
dependabot[bot]
f2ccaacecb chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.3 to 4.26.7 (#2189)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.3 to 4.26.7.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.3...v4.26.7)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:30:29 -04:00
dependabot[bot]
a81ce14046 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#2188)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:29:47 -04:00
dependabot[bot]
6e4a2c8a3d chore(deps): bump docker/build-push-action from 5 to 7 (#2190)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:29:36 -04:00
dependabot[bot]
8cf39be6ef chore(deps): bump github.com/lxzan/gws from 1.9.1 to 1.10.1 (#2193)
Bumps [github.com/lxzan/gws](https://github.com/lxzan/gws) from 1.9.1 to 1.10.1.
- [Release notes](https://github.com/lxzan/gws/releases)
- [Commits](https://github.com/lxzan/gws/compare/v1.9.1...v1.10.1)

---
updated-dependencies:
- dependency-name: github.com/lxzan/gws
  dependency-version: 1.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:56 -04:00
dependabot[bot]
146ca4284c chore(deps): bump actions/setup-go from 5 to 7 (#2192)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 7.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:35 -04:00
dependabot[bot]
6eae195d9c chore(deps): bump klaasnicolaas/action-inactivity-lock (#2194)
Bumps [klaasnicolaas/action-inactivity-lock](https://github.com/klaasnicolaas/action-inactivity-lock) from 1.1.3 to 2.0.1.
- [Release notes](https://github.com/klaasnicolaas/action-inactivity-lock/releases)
- [Commits](https://github.com/klaasnicolaas/action-inactivity-lock/compare/v1.1.3...v2.0.1)

---
updated-dependencies:
- dependency-name: klaasnicolaas/action-inactivity-lock
  dependency-version: 2.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:22 -04:00
dependabot[bot]
30bf65991b chore(deps): bump github.com/fxamacker/cbor/v2 from 2.9.0 to 2.9.2 (#2195)
Bumps [github.com/fxamacker/cbor/v2](https://github.com/fxamacker/cbor) from 2.9.0 to 2.9.2.
- [Release notes](https://github.com/fxamacker/cbor/releases)
- [Commits](https://github.com/fxamacker/cbor/compare/v2.9.0...v2.9.2)

---
updated-dependencies:
- dependency-name: github.com/fxamacker/cbor/v2
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:28:14 -04:00
dependabot[bot]
f57f5883ce chore(deps): bump goreleaser/goreleaser-action from 6 to 7 (#2187)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-06 18:26:35 -04:00
Sven van Ginkel
0472343730 chore: add dependabot and update shoutrrr (#2070)
* chore: update shoutrrr
feat; add dependabot

* Upgrade to shoutrrr 0.16.2

* Dump shoutrrr
2026-08-06 18:24:00 -04:00
henrygd
d3a1d61955 chore: update go version and go mod tidy (#2043) 2026-05-28 14:00:27 -04:00
Sven van Ginkel
9708e24fd2 chore: update Go dependencies (#2043)
* Chore update go depencies

* update crypto package
2026-05-28 13:54:03 -04:00
henrygd
17e910a246 update docker-compose.yml examples 2026-05-19 20:20:13 -04:00
Iriome Santana
0e65a2373f feat(docker): add healthcheck to hub docker-compose example (#2001) 2026-05-19 20:13:29 -04:00
henrygd
c1c1cd1bcb ui: fix temperature chart filtering 2026-04-20 20:45:16 -04:00
Yvan Wang
cd9ea51039 fix(hub): use rfcEmail validator to allow IDN/Punycode email addresses (#1935)
valibot's email() action uses a domain-label regex that disallows the
-- sequence, so Punycode ACE labels like xn--mnchen-3ya.de (the ASCII
form of münchen.de) are incorrectly rejected.

Switching to rfcEmail() applies the RFC 5321 domain-label pattern,
which allows hyphens within labels and therefore accepts both standard
and internationalized domain names.
2026-04-18 12:23:14 -04:00
71 changed files with 1682 additions and 346 deletions

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

View File

@@ -41,7 +41,7 @@ jobs:
# henrygd/beszel-agent-nvidia # henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
registry: docker.io registry: docker.io
username_secret: DOCKERHUB_USERNAME username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN password_secret: DOCKERHUB_TOKEN
@@ -96,7 +96,7 @@ jobs:
# ghcr.io/henrygd/beszel-agent-nvidia # ghcr.io/henrygd/beszel-agent-nvidia
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password_secret: GITHUB_TOKEN password_secret: GITHUB_TOKEN
@@ -152,7 +152,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up bun - name: Set up bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
@@ -164,14 +164,14 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Docker metadata - name: Docker metadata
id: metadata id: metadata
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ matrix.image }} images: ${{ matrix.image }}
tags: ${{ matrix.tags }} tags: ${{ matrix.tags }}
@@ -181,7 +181,7 @@ jobs:
env: env:
password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }}
if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' if: github.event_name != 'pull_request' && env.password_secret_exists == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ matrix.username || secrets[matrix.username_secret] }} username: ${{ matrix.username || secrets[matrix.username_secret] }}
password: ${{ secrets[matrix.password_secret] }} password: ${{ secrets[matrix.password_secret] }}
@@ -190,11 +190,13 @@ jobs:
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
context: ./ context: ./
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
provenance: mode=max
sbom: true
tags: ${{ steps.metadata.outputs.tags }} tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }} labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -15,7 +15,7 @@ jobs:
name: Lock Inactive Issues name: Lock Inactive Issues
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3 - uses: klaasnicolaas/action-inactivity-lock@v2.0.1
id: lock id: lock
with: with:
days-inactive-issues: 14 days-inactive-issues: 14
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
steps: steps:
- name: Close Stale Issues - name: Close Stale Issues
uses: actions/stale@v10 uses: actions/stale@v11
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -27,12 +27,12 @@ jobs:
run: bun run --cwd ./internal/site build run: bun run --cwd ./internal/site build
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v7
with: with:
go-version: "^1.22.1" go-version: "^1.22.1"
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v6
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
@@ -42,7 +42,7 @@ jobs:
shell: bash shell: bash
- name: GoReleaser beszel - name: GoReleaser beszel
uses: goreleaser/goreleaser-action@v6 uses: goreleaser/goreleaser-action@v7
with: with:
workdir: ./ workdir: ./
distribution: goreleaser distribution: goreleaser

View File

@@ -19,11 +19,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code into the Go module directory - name: Check out code into the Go module directory
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v7
with: with:
go-version: 1.26.x go-version: stable
# cached: false # cached: false
- name: Get official govulncheck - name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest run: go install golang.org/x/vuln/cmd/govulncheck@latest

View File

@@ -52,7 +52,7 @@ lint:
golangci-lint run golangci-lint run
test: test:
go test -tags=testing ./... go test -tags='testing no_ui' ./...
tidy: tidy:
go mod tidy go mod tidy

View File

@@ -102,6 +102,7 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if parseErr != nil { if parseErr != nil {
continue continue
} }
cap = min(max(cap, 0), 100)
totalPercent += cap totalPercent += cap
count++ count++

View File

@@ -91,6 +91,16 @@ func TestGetBatteryStats_FullBattery(t *testing.T) {
assert.Equal(t, stateFull, state) assert.Equal(t, stateFull, state)
} }
func TestGetBatteryStats_CapacityClamped(t *testing.T) {
_, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "105", "Charging")
pct, state, err := GetBatteryStats()
assert.NoError(t, err)
assert.Equal(t, uint8(100), pct)
assert.Equal(t, stateCharging, state)
}
func TestGetBatteryStats_EmptyBattery(t *testing.T) { func TestGetBatteryStats_EmptyBattery(t *testing.T) {
_, addBattery := setupFakeSysfs(t) _, addBattery := setupFakeSysfs(t)
addBattery("BAT0", "0", "Empty") addBattery("BAT0", "0", "Empty")

View File

@@ -53,8 +53,8 @@ func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) {
client = &WebSocketClient{} client = &WebSocketClient{}
client.hubURL, err = url.Parse(hubURLStr) client.hubURL, err = url.Parse(hubURLStr)
if err != nil { if err != nil || client.hubURL.Host == "" {
return nil, errors.New("invalid hub URL") return nil, fmt.Errorf("invalid HUB_URL %q: must include scheme and host (e.g. http://hub.example.com:8090)", hubURLStr)
} }
// get registration token // get registration token
client.token, err = getToken() client.token, err = getToken()

View File

@@ -51,11 +51,18 @@ func TestNewWebSocketClient(t *testing.T) {
errorMsg: "HUB_URL environment variable not set", errorMsg: "HUB_URL environment variable not set",
}, },
{ {
name: "invalid URL", name: "malformed URL",
hubURL: "ht\ttp://invalid", hubURL: "ht\ttp://invalid",
token: "test-token", token: "test-token",
expectError: true, expectError: true,
errorMsg: "invalid hub URL", errorMsg: "invalid HUB_URL",
},
{
name: "URL without host",
hubURL: "http:/api",
token: "test-token",
expectError: true,
errorMsg: "invalid HUB_URL",
}, },
{ {
name: "missing token", name: "missing token",

View File

@@ -65,7 +65,6 @@ type dockerManager struct {
dockerVersionChecked bool // Whether a version probe has completed successfully dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman usingPodman bool // Whether the Docker Engine API is running on Podman
@@ -374,16 +373,26 @@ func convertContainerPortsToString(ctr *container.ApiInfo) string {
return "" return ""
} }
sort.Slice(ctr.Ports, func(i, j int) bool { sort.Slice(ctr.Ports, func(i, j int) bool {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort if ctr.Ports[i].PublicPort != ctr.Ports[j].PublicPort {
return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort
}
return ctr.Ports[i].IP < ctr.Ports[j].IP
}) })
var builder strings.Builder var builder strings.Builder
seenPorts := make(map[uint16]struct{}) seen := make(map[string]struct{})
for _, p := range ctr.Ports { for _, p := range ctr.Ports {
_, ok := seenPorts[p.PublicPort] if p.PublicPort == 0 {
if p.PublicPort == 0 || ok {
continue continue
} }
seenPorts[p.PublicPort] = struct{}{} keyIP := p.IP
if keyIP == "0.0.0.0" || keyIP == "::" {
keyIP = ""
}
key := keyIP + ":" + strconv.Itoa(int(p.PublicPort))
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if builder.Len() > 0 { if builder.Len() > 0 {
builder.WriteString(", ") builder.WriteString(", ")
} }
@@ -747,20 +756,18 @@ func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo
} }
} }
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. // Decodes a Docker API JSON response using a reusable buffer. Not thread safe.
func (dm *dockerManager) decode(resp *http.Response, d any) error { func (dm *dockerManager) decode(resp *http.Response, d any) error {
if dm.buf == nil { if dm.buf == nil {
// initialize buffer with 256kb starting size // initialize buffer with 256kb starting size
dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256))
dm.decoder = json.NewDecoder(dm.buf)
} }
defer resp.Body.Close() defer resp.Body.Close()
defer dm.buf.Reset() defer dm.buf.Reset()
_, err := dm.buf.ReadFrom(resp.Body) if _, err := dm.buf.ReadFrom(resp.Body); err != nil {
if err != nil {
return err return err
} }
return dm.decoder.Decode(d) return json.Unmarshal(dm.buf.Bytes(), d)
} }
// Test docker / podman sockets and return if one exists // Test docker / podman sockets and return if one exists

View File

@@ -804,6 +804,24 @@ func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
assert.Equal(t, 2, requestCounts["/version"]) assert.Equal(t, 2, requestCounts["/version"])
} }
// A failed decode must not break later decodes. Previously the reused json.Decoder
// stayed desynced after one truncated response, breaking decode until restart.
func TestDecodeRecoversFromError(t *testing.T) {
dm := &dockerManager{}
// truncated JSON: body reads fine, decode fails
var bad []container.ApiInfo
err := dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abc`))}, &bad)
require.Error(t, err)
// the next decode must still succeed
var good []container.ApiInfo
err = dm.decode(&http.Response{Body: io.NopCloser(strings.NewReader(`[{"Id":"abcdef012345","Names":["/ok"]}]`))}, &good)
require.NoError(t, err)
require.Len(t, good, 1)
assert.Equal(t, "abcdef012345", good[0].Id)
}
func TestCycleCpuDeltas(t *testing.T) { func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{ dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{ lastCpuContainer: map[uint16]map[string]uint64{
@@ -1003,6 +1021,44 @@ func TestCpuPercentageCalculationWithRealData(t *testing.T) {
assert.InDelta(t, expectedPct, actualPct, 0.01) assert.InDelta(t, expectedPct, actualPct, 0.01)
} }
func TestCpuPercentageHandlesCounterRollback(t *testing.T) {
// If a stats response is processed after a newer one for the same container,
// or an accounting counter resets, the current total can be lower than the
// stored previous value. Unsigned subtraction wraps to ~2^64 instead of
// going negative, so the percentage explodes, validateCpuPercentage rejects
// the sample, and the whole collection is discarded - network stats too.
stats := &container.ApiStats{
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
SystemUsage: 20_000_000,
},
}
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(2_000_000, 10_000_000))
// System counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentLinux(500_000, 30_000_000))
// A normal forward sample is unaffected: 500000 / 10000000 * 100 = 5%.
assert.InDelta(t, 5.0, stats.CalculateCpuPercentLinux(500_000, 10_000_000), 0.001)
}
func TestCpuPercentageWindowsHandlesCounterRollback(t *testing.T) {
now := time.Now()
stats := &container.ApiStats{
Read: now,
NumProcs: 4,
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 1_000_000},
},
}
prevRead := now.Add(-time.Second)
// Container counter went backwards.
assert.Equal(t, 0.0, stats.CalculateCpuPercentWindows(2_000_000, prevRead))
// A normal forward sample is unaffected.
assert.Greater(t, stats.CalculateCpuPercentWindows(500_000, prevRead), 0.0)
}
func TestNetworkStatsCalculationWithRealData(t *testing.T) { func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Create synthetic test data to avoid timing issues // Create synthetic test data to avoid timing issues
apiStats1 := &container.ApiStats{ apiStats1 := &container.ApiStats{
@@ -1864,6 +1920,14 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 443", expected: "80, 443",
}, },
{
name: "ipv4 and ipv6 wildcard bindings are deduplicated",
ports: []port{
{PublicPort: 80, IP: "0.0.0.0"},
{PublicPort: 80, IP: "::"},
},
expected: "80",
},
{ {
name: "multiple ports with different IPs", name: "multiple ports with different IPs",
ports: []port{ ports: []port{
@@ -1872,6 +1936,22 @@ func TestConvertContainerPortsToString(t *testing.T) {
}, },
expected: "80, 1.2.3.4:443", expected: "80, 1.2.3.4:443",
}, },
{
name: "same port bound to multiple IPs shows all entries",
ports: []port{
{PublicPort: 65533, IP: "172.16.151.72"},
{PublicPort: 65533, IP: "172.16.156.25"},
},
expected: "172.16.151.72:65533, 172.16.156.25:65533",
},
{
name: "same port bound to IPv4 and IPv6",
ports: []port{
{PublicPort: 65534, IP: "172.16.151.72"},
{PublicPort: 65534, IP: "fd04:38e2:98c6:3fd::72"},
},
expected: "172.16.151.72:65534, fd04:38e2:98c6:3fd::72:65534",
},
{ {
name: "ports slice is nilled after call", name: "ports slice is nilled after call",
ports: []port{ ports: []port{

101
agent/fans.go Normal file
View File

@@ -0,0 +1,101 @@
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
type fanSensor struct {
key, path string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
func newFanSensorCache(root string) func() ([]fanSensor, error) {
return sync.OnceValues(func() ([]fanSensor, error) {
return discoverHwmonFans(root)
})
}
// updateFans populates systemStats.Fans from the host's hwmon sysfs tree.
// No-op on platforms where hwmon isn't available (see fans_other.go).
func (a *Agent) updateFans(systemStats *system.Stats) {
if hwmonRoot == "" {
return
}
sensors, err := getFanSensors()
if err != nil {
slog.Debug("Error reading fans", "err", err)
return
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
}
systemStats.Fans = fans
// Note: Commented out because we don't currently use this value in the UI.
// Compute the single "dashboard" value used by the FanSpeed alert.
// Per-sensor RPMs live in Stats.Fans and drive the multi-line FanChart
// in the UI; the alert path only needs one number to compare against
// the user's threshold, so we use the highest RPM across all fans
// a.systemInfo.DashboardFan = 0
// for _, rpm := range fans {
// if rpm > a.systemInfo.DashboardFan {
// a.systemInfo.DashboardFan = rpm
// }
// }
}
// readHwmonFans walks the given hwmon root (typically /sys/class/hwmon) and
// returns a map of "<chip>_<label-or-fan-idx>" → RPM for every fan*_input
// file it finds. Zero RPM is retained because it can represent a real fan that
// has stopped; negative and malformed readings are ignored.
func readHwmonFans(root string) (map[string]uint16, error) {
sensors, err := discoverHwmonFans(root)
if err != nil {
return nil, err
}
return readFanSensors(sensors), nil
}
func discoverHwmonFans(root string) ([]fanSensor, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var sensors []fanSensor
for _, entry := range entries {
chipDir := filepath.Join(root, entry.Name())
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name"))
if chipName == "" {
chipName = entry.Name()
}
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label"))
key := chipName + "_" + base
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath})
}
}
return sensors, nil
}
func readFanSensors(sensors []fanSensor) map[string]uint16 {
fans := make(map[string]uint16, len(sensors))
for _, sensor := range sensors {
if rpm, ok := utils.ReadUintFile(sensor.path); ok {
fans[sensor.key] = uint16(rpm)
}
}
return fans
}

8
agent/fans_linux.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build linux
package agent
// hwmonRoot is the sysfs entry point for hardware monitor chips. Each
// subdirectory (hwmon0, hwmon1, …) is one chip; fan*_input files inside it
// expose RPM readings.
const hwmonRoot = "/sys/class/hwmon"

7
agent/fans_other.go Normal file
View File

@@ -0,0 +1,7 @@
//go:build !linux
package agent
// hwmonRoot is empty on non-Linux platforms — fan RPM reporting via sysfs
// hwmon is Linux-specific. updateFans() short-circuits when this is empty.
const hwmonRoot = ""

87
agent/fans_test.go Normal file
View File

@@ -0,0 +1,87 @@
//go:build testing
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// writeFile creates path with parents and writes contents.
func writeFile(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// TestReadHwmonFans verifies the /sys/class/hwmon walker:
// - picks up fan*_input from every chip,
// - keys entries by chip name + sensor label (or fan idx if no label),
// - retains 0 RPM for stopped fans,
// - tolerates chips with no fan files at all.
func TestReadHwmonFans(t *testing.T) {
root := t.TempDir()
// hwmon0: Raspberry Pi 5 active cooler — one fan, no label.
writeFile(t, filepath.Join(root, "hwmon0", "name"), "pwmfan\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "6500\n")
// hwmon1: a thermal-only chip, no fan files. Must not error.
writeFile(t, filepath.Join(root, "hwmon1", "name"), "cpu_thermal\n")
writeFile(t, filepath.Join(root, "hwmon1", "temp1_input"), "55000\n")
// hwmon2: two fans — one stopped (0 RPM) and one labeled "chassis".
writeFile(t, filepath.Join(root, "hwmon2", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan1_input"), "0\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon2", "fan2_label"), "chassis\n")
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{
"pwmfan_fan1": 6500,
"nct6798_fan1": 0,
"nct6798_chassis": 1200,
}, fans)
}
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) {
_, err := readHwmonFans(filepath.Join(t.TempDir(), "does-not-exist"))
assert.Error(t, err)
}
// TestReadHwmonFansEmpty returns an empty map (not nil error) when the root
// exists but contains no chips at all.
func TestReadHwmonFansEmpty(t *testing.T) {
root := t.TempDir()
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Empty(t, fans)
}
func TestFanDiscoveryCache(t *testing.T) {
root := t.TempDir()
input := filepath.Join(root, "hwmon0", "fan1_input")
writeFile(t, filepath.Join(root, "hwmon0", "name"), "chip\n")
writeFile(t, input, "1000\n")
getSensors := newFanSensorCache(root)
sensors, err := getSensors()
require.NoError(t, err)
fans := readFanSensors(sensors)
assert.Equal(t, uint16(1000), fans["chip_fan1"])
writeFile(t, input, "1200\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_label"), "case\n")
sensors, err = getSensors()
require.NoError(t, err)
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}

View File

@@ -166,14 +166,16 @@ type GetSmartDataHandler struct{}
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error { func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.smartManager == nil { if hctx.Agent.smartManager == nil {
// return empty map to indicate no data return hctx.SendResponse(smart.SmartDataResponse{Data: map[string]smart.SmartData{}}, hctx.RequestID)
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
} }
if err := hctx.Agent.smartManager.Refresh(false); err != nil { complete, err := hctx.Agent.smartManager.Refresh(false)
if err != nil {
slog.Debug("smart refresh failed", "err", err) slog.Debug("smart refresh failed", "err", err)
} }
data := hctx.Agent.smartManager.GetCurrentData() return hctx.SendResponse(smart.SmartDataResponse{
return hctx.SendResponse(data, hctx.RequestID) Data: hctx.Agent.smartManager.GetCurrentData(),
Complete: complete,
}, hctx.RequestID)
} }
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////

View File

@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -17,6 +18,18 @@ type MockHandler struct {
handleFunc func(ctx *HandlerContext) error handleFunc func(ctx *HandlerContext) error
} }
func TestNewAgentResponseSmartData(t *testing.T) {
response := newAgentResponse(smart.SmartDataResponse{
Data: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
Complete: true,
}, nil)
assert.Equal(t, "AAA", response.SmartData["AAA"].SerialNumber)
assert.True(t, response.SmartComplete)
}
func (m *MockHandler) Handle(ctx *HandlerContext) error { func (m *MockHandler) Handle(ctx *HandlerContext) error {
if m.handleFunc != nil { if m.handleFunc != nil {
return m.handleFunc(ctx) return m.handleFunc(ctx)

View File

@@ -180,8 +180,13 @@ func mdraidSmartStatus(health mdraidHealth) string {
if health.degraded > 0 { if health.degraded > 0 {
return "FAILED" return "FAILED"
} }
if health.mismatchCnt > 0 {
return "WARNING"
}
// "check" scans for consistency problems without repairing mismatches.
// With no mismatches, keep it green while reporting progress attributes.
switch syncAction { switch syncAction {
case "check", "repair": case "repair":
return "WARNING" return "WARNING"
} }
switch state { switch state {

View File

@@ -94,6 +94,18 @@ func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean+check) = %q, want PASSED", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "check", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+check+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
}
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got) t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
} }

View File

@@ -21,6 +21,9 @@ func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response.String = &v response.String = &v
case map[string]smart.SmartData: case map[string]smart.SmartData:
response.SmartData = v response.SmartData = v
case smart.SmartDataResponse:
response.SmartData = v.Data
response.SmartComplete = v.Complete
case systemd.ServiceDetails: case systemd.ServiceDetails:
response.ServiceInfo = v response.ServiceInfo = v
default: default:

View File

@@ -29,9 +29,6 @@ type ServerOptions struct {
Keys []gossh.PublicKey // SSH public keys for authentication Keys []gossh.PublicKey // SSH public keys for authentication
} }
// hubVersions caches hub versions by session ID to avoid repeated parsing.
var hubVersions map[string]semver.Version
// StartServer starts the SSH server with the provided options. // StartServer starts the SSH server with the provided options.
// It configures the server with secure defaults, sets up authentication, // It configures the server with secure defaults, sets up authentication,
// and begins listening for connections. Returns an error if the server // and begins listening for connections. Returns an error if the server
@@ -99,24 +96,15 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln) return a.server.Serve(ln)
} }
// getHubVersion retrieves and caches the hub version for a given session. // getHubVersion extracts the hub version from the SSH client version string
// It extracts the version from the SSH client version string and caches // for a given session. Returns a zero version if parsing fails.
// it to avoid repeated parsing. Returns a zero version if parsing fails. func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version {
if hubVersions == nil {
hubVersions = make(map[string]semver.Version, 1)
}
hubVersion, ok := hubVersions[sessionId]
if ok {
return hubVersion
}
// Extract hub version from SSH client version
clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion)
if versionStr, ok := clientVersion.(string); ok { if versionStr, ok := clientVersion.(string); ok {
hubVersion, _ = extractHubVersion(versionStr) hubVersion, _ := extractHubVersion(versionStr)
return hubVersion
} }
hubVersions[sessionId] = hubVersion return semver.Version{}
return hubVersion
} }
// handleSession handles an incoming SSH session by gathering system statistics // handleSession handles an incoming SSH session by gathering system statistics
@@ -127,9 +115,8 @@ func (a *Agent) handleSession(s ssh.Session) {
a.connectionManager.eventChan <- SSHConnect a.connectionManager.eventChan <- SSHConnect
sessionCtx := s.Context() sessionCtx := s.Context()
sessionID := sessionCtx.SessionID()
hubVersion := a.getHubVersion(sessionID, sessionCtx) hubVersion := a.getHubVersion(sessionCtx)
// Legacy one-shot behavior for older hubs // Legacy one-shot behavior for older hubs
if hubVersion.LT(beszel.MinVersionAgentResponse) { if hubVersion.LT(beszel.MinVersionAgentResponse) {

View File

@@ -404,27 +404,23 @@ func TestGetHubVersion(t *testing.T) {
clientVersion: "SSH-2.0-beszel_0.12.0", clientVersion: "SSH-2.0-beszel_0.12.0",
} }
// Test first call - should extract and cache version // Test first call - should extract version
version := agent.getHubVersion("test-session-123", mockCtx) version := agent.getHubVersion(mockCtx)
assert.Equal(t, "0.12.0", version.String()) assert.Equal(t, "0.12.0", version.String())
// Test second call - should return cached version // Test that version reflects the current client version (no stale caching)
mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0"
version = agent.getHubVersion("test-session-123", mockCtx) version = agent.getHubVersion(mockCtx)
assert.Equal(t, "0.12.0", version.String()) // Should still be cached version
// Test different session - should extract new version
version = agent.getHubVersion("different-session", mockCtx)
assert.Equal(t, "0.11.0", version.String()) assert.Equal(t, "0.11.0", version.String())
// Test with invalid version string (non-beszel client) // Test with invalid version string (non-beszel client)
mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0"
version = agent.getHubVersion("invalid-session", mockCtx) version = agent.getHubVersion(mockCtx)
assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients
// Test with no client version // Test with no client version
mockCtx.clientVersion = "" mockCtx.clientVersion = ""
version = agent.getHubVersion("no-version-session", mockCtx) version = agent.getHubVersion(mockCtx)
assert.True(t, version.EQ(semver.Version{})) // Should be empty version assert.True(t, version.EQ(semver.Version{})) // Should be empty version
} }
@@ -501,9 +497,6 @@ func TestWriteToSessionEncoding(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Reset the global hubVersions map to ensure clean state for each test
hubVersions = nil
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
@@ -585,39 +578,28 @@ func createTestCombinedData() *system.CombinedData {
} }
} }
func TestHubVersionCaching(t *testing.T) { // TestGetHubVersionConcurrent guards against a regression of the
// Reset the global hubVersions map to ensure clean state // "concurrent map writes" panic previously caused by a shared, unsynchronized
hubVersions = nil // hubVersions cache (see https://github.com/henrygd/beszel/issues/2128).
// getHubVersion no longer shares mutable state between sessions, so calling
// it concurrently from many goroutines must be safe under `go test -race`.
func TestGetHubVersionConcurrent(t *testing.T) {
agent, err := NewAgent("") agent, err := NewAgent("")
require.NoError(t, err) require.NoError(t, err)
ctx1 := &mockSSHContext{ const goroutines = 50
sessionID: "session1", var wg sync.WaitGroup
clientVersion: "SSH-2.0-beszel_0.12.0", wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func(i int) {
defer wg.Done()
ctx := &mockSSHContext{
sessionID: fmt.Sprintf("session-%d", i),
clientVersion: "SSH-2.0-beszel_0.12.0",
}
version := agent.getHubVersion(ctx)
assert.Equal(t, "0.12.0", version.String())
}(i)
} }
ctx2 := &mockSSHContext{ wg.Wait()
sessionID: "session2",
clientVersion: "SSH-2.0-beszel_0.11.0",
}
// First calls should cache the versions
v1 := agent.getHubVersion("session1", ctx1)
v2 := agent.getHubVersion("session2", ctx2)
assert.Equal(t, "0.12.0", v1.String())
assert.Equal(t, "0.11.0", v2.String())
// Verify caching by changing context but keeping same session ID
ctx1.clientVersion = "SSH-2.0-beszel_0.10.0"
v1Cached := agent.getHubVersion("session1", ctx1)
assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version
// New session should get new version
ctx3 := &mockSSHContext{
sessionID: "session3",
clientVersion: "SSH-2.0-beszel_0.13.0",
}
v3 := agent.getHubVersion("session3", ctx3)
assert.Equal(t, "0.13.0", v3.String())
} }

View File

@@ -55,6 +55,11 @@ type DeviceInfo struct {
typeVerified bool typeVerified bool
// parserType holds the parser type (nvme, sat, scsi) that last succeeded. // parserType holds the parser type (nvme, sat, scsi) that last succeeded.
parserType string parserType string
// explicitType reports whether Type came from an explicit ":type" hint in
// SMART_DEVICES. Such a type is a deliberate user override and must always be
// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
// otherwise left off (see smartctlArgs and issue #1345).
explicitType bool
} }
// deviceKey is a composite key for a device, used to identify a device uniquely. // deviceKey is a composite key for a device, used to identify a device uniquely.
@@ -65,8 +70,9 @@ type deviceKey struct {
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices // Refresh updates SMART data for all known devices and reports whether every
func (sm *SmartManager) Refresh(forceScan bool) error { // discovered device was collected successfully.
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
sm.refreshMutex.Lock() sm.refreshMutex.Lock()
defer sm.refreshMutex.Unlock() defer sm.refreshMutex.Unlock()
@@ -87,7 +93,7 @@ func (sm *SmartManager) Refresh(forceScan bool) error {
} }
} }
return sm.resolveRefreshError(scanErr, collectErr) return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
} }
// devicesSnapshot returns a copy of the current device slice to avoid iterating // devicesSnapshot returns a copy of the current device slice to avoid iterating
@@ -251,8 +257,9 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
} }
devices = append(devices, &DeviceInfo{ devices = append(devices, &DeviceInfo{
Name: name, Name: name,
Type: devType, Type: devType,
explicitType: devType != "",
}) })
} }
@@ -558,7 +565,9 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
deviceType = strings.ToLower(deviceInfo.Type) deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType) parserType = strings.ToLower(deviceInfo.parserType)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345 // types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" { // An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
args = append(args, "-d", deviceInfo.Type) args = append(args, "-d", deviceInfo.Type)
} }
} }
@@ -663,6 +672,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.Type = prev.Type target.Type = prev.Type
target.typeVerified = true target.typeVerified = true
target.parserType = prev.parserType target.parserType = prev.parserType
if prev.explicitType {
target.explicitType = true
}
} }
// applyConfiguredMetadata updates a matched device with any configured // applyConfiguredMetadata updates a matched device with any configured
@@ -676,6 +688,9 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
existingDev.typeVerified = false existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType) existingDev.parserType = normalizeParserType(newType)
} }
if configuredDev.explicitType {
existingDev.explicitType = true
}
if configuredDev.InfoName != "" { if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName existingDev.InfoName = configuredDev.InfoName
} }

View File

@@ -392,6 +392,81 @@ func TestSmartctlArgs(t *testing.T) {
) )
} }
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
// still left off so smartctl can auto-detect it (see issue #1345).
func TestSmartctlArgsExplicitType(t *testing.T) {
sm := &SmartManager{}
// Scan-detected scsi: -d is intentionally omitted.
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
assert.Equal(t,
[]string{"-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(scanScsi, false),
)
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(explicitScsi, false),
)
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
assert.Equal(t,
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
sm.smartctlArgs(explicitAta, false),
)
}
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
sm := &SmartManager{}
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
require.NoError(t, err)
require.Len(t, configured, 1)
assert.True(t, configured[0].explicitType)
// smartctl --scan misreports this USB drive as sat, which fails on it.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
}
merged := mergeDeviceLists(nil, scanned, configured)
require.Len(t, merged, 1)
device := merged[0]
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
assert.True(t, device.explicitType, "explicit hint must survive the merge")
assert.Equal(t,
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(device, false),
"explicit scsi type must be passed to smartctl, not dropped",
)
}
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
// explicitly-typed device keeps its explicit flag when a later scan re-reports
// it with a different auto-detected type.
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
existing := []*DeviceInfo{
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
}
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "sat"},
}
merged := mergeDeviceLists(existing, scanned, nil)
require.Len(t, merged, 1)
assert.Equal(t, "scsi", merged[0].Type)
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
}
func TestResolveRefreshError(t *testing.T) { func TestResolveRefreshError(t *testing.T) {
scanErr := errors.New("scan failed") scanErr := errors.New("scan failed")
collectErr := errors.New("collect failed") collectErr := errors.New("collect failed")

View File

@@ -169,21 +169,11 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// memory // memory
if v, err := mem.VirtualMemory(); err == nil { if v, err := mem.VirtualMemory(); err == nil {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(v, a.memCalc == "htop")
// swap // swap
systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal)
systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) systemStats.SwapUsed = utils.BytesToGigabytes(swapUsed)
// cache + buffers value for default mem calculation v.Used = used
// note: gopsutil automatically adds SReclaimable to v.Cached
cacheBuff := v.Cached + v.Buffers - v.Shared
if cacheBuff <= 0 {
cacheBuff = max(v.Total-v.Free-v.Used, 0)
}
// htop memory calculation overrides (likely outdated as of mid 2025)
if a.memCalc == "htop" {
// cacheBuff = v.Cached + v.Buffers - v.Shared
v.Used = v.Total - (v.Free + cacheBuff)
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
}
// if a.memCalc == "legacy" { // if a.memCalc == "legacy" {
// v.Used = v.Total - v.Free - v.Buffers - v.Cached // v.Used = v.Total - v.Free - v.Buffers - v.Cached
// cacheBuff = v.Total - v.Free - v.Used // cacheBuff = v.Total - v.Free - v.Used
@@ -193,10 +183,14 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
if a.zfs { if a.zfs {
if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used {
v.Used = v.Used - arcSize v.Used = v.Used - arcSize
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize)
} }
} }
if v.Total > 0 {
v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0
} else {
v.UsedPercent = 0
}
systemStats.Mem = utils.BytesToGigabytes(v.Total) systemStats.Mem = utils.BytesToGigabytes(v.Total)
systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff)
systemStats.MemUsed = utils.BytesToGigabytes(v.Used) systemStats.MemUsed = utils.BytesToGigabytes(v.Used)
@@ -216,6 +210,9 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
// TODO: maybe refactor to methods on systemStats // TODO: maybe refactor to methods on systemStats
a.updateTemperatures(&systemStats) a.updateTemperatures(&systemStats)
// fan speeds (Linux-only; sysfs hwmon)
a.updateFans(&systemStats)
// GPU data // GPU data
if a.gpuManager != nil { if a.gpuManager != nil {
// reset high gpu percent // reset high gpu percent
@@ -263,6 +260,38 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
return systemStats return systemStats
} }
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {
used = v.Used
if used > v.Total {
used = saturatingSub(v.Total, v.Available)
}
// gopsutil automatically adds SReclaimable to Cached.
cacheBuff = min(v.Cached, v.Total)
cacheBuff += min(v.Buffers, v.Total-cacheBuff)
cacheBuff = saturatingSub(cacheBuff, min(v.Shared, v.Total))
if v.Cached == 0 && v.Buffers == 0 {
cacheBuff = saturatingSub(v.Total, v.Free, used)
}
if htop {
used = saturatingSub(v.Total, v.Free, cacheBuff)
}
return used, cacheBuff, saturatingSub(v.SwapTotal, v.SwapFree, v.SwapCached)
}
// saturatingSub subtracts each value, returning zero on underflow.
func saturatingSub(value uint64, subtrahends ...uint64) uint64 {
for _, subtrahend := range subtrahends {
if subtrahend > value {
return 0
}
value -= subtrahend
}
return value
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) { func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release") file, err := os.Open("/etc/os-release")

View File

@@ -5,6 +5,7 @@ import (
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/mem"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -33,6 +34,59 @@ func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
assert.Nil(t, secondResponse.Details) assert.Nil(t, secondResponse.Details)
} }
func TestCalculateHostMemoryUsage(t *testing.T) {
tests := []struct {
name string
memory mem.VirtualMemoryStat
htop bool
used, cacheBuff, swapUsed uint64
}{
{
name: "normal",
memory: mem.VirtualMemoryStat{Total: 100, Available: 40, Used: 60, Free: 20, Cached: 25, Buffers: 10, Shared: 5, SwapTotal: 20, SwapFree: 8, SwapCached: 2},
used: 60,
cacheBuff: 30,
swapUsed: 10,
},
{
name: "inconsistent counters saturate",
memory: mem.VirtualMemoryStat{Total: 100, Available: 110, Used: ^uint64(0) - 9, Free: 90, Cached: 5, Buffers: 10, Shared: 20, SwapTotal: 10, SwapFree: 9, SwapCached: 2},
used: 0,
cacheBuff: 0,
swapUsed: 0,
},
{
name: "htop subtraction saturates",
memory: mem.VirtualMemoryStat{Total: 100, Available: 20, Used: 80, Free: 90, Cached: 20, Buffers: 5, SwapTotal: 30, SwapFree: 10, SwapCached: 5},
htop: true,
used: 0,
cacheBuff: 25,
swapUsed: 15,
},
{
name: "zero cache from shared cancellation does not fall back",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10, Cached: 20, Buffers: 10, Shared: 30},
used: 60,
cacheBuff: 0,
},
{
name: "absent cache counters use fallback",
memory: mem.VirtualMemoryStat{Total: 100, Used: 60, Free: 10},
used: 60,
cacheBuff: 30,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
used, cacheBuff, swapUsed := calculateHostMemoryUsage(&tt.memory, tt.htop)
assert.Equal(t, tt.used, used)
assert.Equal(t, tt.cacheBuff, cacheBuff)
assert.Equal(t, tt.swapUsed, swapUsed)
})
}
}
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) { func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
agent := &Agent{} agent := &Agent{}

57
go.mod
View File

@@ -1,26 +1,27 @@
module github.com/henrygd/beszel module github.com/henrygd/beszel
go 1.26.1 go 1.26.6
require ( require (
github.com/blang/semver v3.5.1+incompatible github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0 github.com/coreos/go-systemd/v22 v22.7.0
github.com/ebitengine/purego v0.10.0 github.com/ebitengine/purego v0.10.2
github.com/fxamacker/cbor/v2 v2.9.0 github.com/fxamacker/cbor/v2 v2.9.2
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.9.1 github.com/lxzan/gws v1.10.1
github.com/nicholas-fedor/shoutrrr v0.14.3 github.com/nicholas-fedor/shoutrrr v0.17.0
github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.8 github.com/pocketbase/pocketbase v0.39.10
github.com/shirou/gopsutil/v4 v4.26.3 github.com/shirou/gopsutil/v4 v4.26.7
github.com/spf13/cast v1.10.0 github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.55.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f
golang.org/x/sys v0.42.0 golang.org/x/net v0.57.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1 howett.net/plist v1.0.1
) )
@@ -34,35 +35,35 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.golang v0.23.0 // indirect github.com/eclipse/paho.golang v0.23.0 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.19.2 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.38.0 // indirect golang.org/x/image v0.45.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.41.0 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.12.0 // indirect
modernc.org/sqlite v1.48.0 // indirect modernc.org/sqlite v1.55.0 // indirect
) )

162
go.sum
View File

@@ -1,7 +1,7 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -21,29 +21,29 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk= github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI= github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
@@ -56,8 +56,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -66,47 +66,49 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o= github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc= github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig= github.com/nicholas-fedor/shoutrrr v0.17.0 h1:xfp3z5QbE8jXvUhUEwWDk47SJ/b912VoB8MJJDU+q4E=
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs= github.com/nicholas-fedor/shoutrrr v0.17.0/go.mod h1:s4ldyLs6uwBy9lIjYrY+8lyTqJtPvZSrILw0CyMLock=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c= github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA= github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/pocketbase/pocketbase v0.39.10 h1:2j8TDJRuo3aAC8Y8F9WFux0SwYcxeDCgEYQxxdWkwGE=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pocketbase/pocketbase v0.39.10/go.mod h1:tSX3anHQ7Ul6dPV9WhlEc6No1DtklGF69iwnVNW3BEE=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3bNZc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -120,51 +122,51 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f h1:iXpLj9sdDH/RLYsnOMpbETK6KWtrHwvegcc4psWJHV8=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/exp v0.0.0-20260811152304-ee035b5b010f/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -175,30 +177,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -322,8 +322,9 @@ func TestAlertSilencedMultiUser(t *testing.T) {
} }
func TestAlertSilencedWithActualAlert(t *testing.T) { func TestAlertSilencedWithActualAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system

View File

@@ -29,8 +29,9 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
} }
func TestStatusAlerts(t *testing.T) { func TestStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused") systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
@@ -234,8 +235,9 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
} }
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) { func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
@@ -336,8 +338,9 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) { func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -423,8 +426,9 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
} }
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) { func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -816,8 +820,9 @@ func TestResolveStatusAlerts(t *testing.T) {
} }
func TestAlertsHistoryStatus(t *testing.T) { func TestAlertsHistoryStatus(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system
@@ -882,8 +887,9 @@ func TestAlertsHistoryStatus(t *testing.T) {
} }
func TestStatusAlertClearedBeforeSend(t *testing.T) { func TestStatusAlertClearedBeforeSend(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create a system // Create a system

View File

@@ -95,11 +95,10 @@ func waitForSystemAlert(d time.Duration) {
func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) { func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, 1, threshold)
defer fixture.cleanup()
submitValue(fixture, t, triggerValue, setValue) submitValue(fixture, t, triggerValue, setValue)
waitForSystemAlert(time.Second) waitForSystemAlert(time.Second)
@@ -118,11 +117,10 @@ func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold f
func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) { func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) {
t.Helper() t.Helper()
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
fixture := newSystemAlertTestFixture(t, alertName, min, threshold)
defer fixture.cleanup()
submitValue(fixture, t, baselineValue, setValue) submitValue(fixture, t, baselineValue, setValue)
waitForSystemAlert(time.Minute + time.Second) waitForSystemAlert(time.Minute + time.Second)
fixture.assertTriggered(t, false, "Alert should not be triggered yet") fixture.assertTriggered(t, false, "Alert should not be triggered yet")

View File

@@ -15,8 +15,9 @@ import (
) )
func TestAlertsHistory(t *testing.T) { func TestAlertsHistory(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
synctest.Test(t, func(t *testing.T) { synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup() defer hub.Cleanup()
// Create systems and alerts // Create systems and alerts

View File

@@ -42,7 +42,8 @@ type AgentResponse struct {
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,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 is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"` Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
SmartComplete bool `cbor:"8,keyasint,omitempty,omitzero"`
} }
type FingerprintRequest struct { type FingerprintRequest struct {

View File

@@ -52,6 +52,17 @@ type HostInfo struct {
} }
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
// A counter can read lower than the stored previous value when a stats
// response is processed after a newer one for the same container, or when an
// accounting counter resets. Unsigned subtraction wraps to ~2^64 instead of
// going negative: on the container counter that surfaces as an absurd
// percentage the caller rejects, discarding the whole sample; on the system
// counter it inflates the divisor and silently reports near-zero CPU.
// Treat either direction as a new baseline.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuContainer || s.CPUStats.SystemUsage < prevCpuSystem {
return 0.0
}
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
@@ -70,7 +81,11 @@ func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time
possIntervals /= 100 // Convert to number of 100ns intervals possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
// Intervals used // Intervals used. Same rollback guard as the Linux path: an out-of-order or
// reset counter would wrap the subtraction to ~2^64.
if s.CPUStats.CPUUsage.TotalUsage < prevCpuUsage {
return 0.0
}
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
// Percentage avoiding divide-by-zero // Percentage avoiding divide-by-zero

View File

@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
FirmwareVersion string `json:"firmware_version"` FirmwareVersion string `json:"firmware_version"`
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"` // NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"` // NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"` NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"` // NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
// NVMeControllerID uint16 `json:"nvme_controller_id"` // NVMeControllerID uint16 `json:"nvme_controller_id"`
// NVMeVersion VersionStringInfo `json:"nvme_version"` // NVMeVersion VersionStringInfo `json:"nvme_version"`
@@ -531,6 +531,13 @@ type SmartData struct {
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"` Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
} }
// SmartDataResponse contains the collected data and whether every discovered
// device was collected. Older agents omit Complete, so hubs must not prune from it.
type SmartDataResponse struct {
Data map[string]SmartData `json:"data" cbor:"0,keyasint"`
Complete bool `json:"complete" cbor:"1,keyasint,omitempty"` // Whether every discovered device was collected
}
type SmartAttribute struct { type SmartAttribute struct {
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"` ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
Name string `json:"n" cbor:"1,keyasint"` Name string `json:"n" cbor:"1,keyasint"`

View File

@@ -33,6 +33,7 @@ type Stats struct {
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"`
Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"`
Fans map[string]uint16 `json:"f,omitempty" cbor:"36,keyasint,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"`
// LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` // LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"`

View File

@@ -0,0 +1,43 @@
package ghupdate
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
)
func verifyAssetChecksum(path, digest string) error {
algorithm, expectedHex, ok := strings.Cut(digest, ":")
if !ok || algorithm == "" || expectedHex == "" {
return fmt.Errorf("invalid release digest %q", digest)
}
if !strings.EqualFold(algorithm, "sha256") {
return fmt.Errorf("unsupported release digest algorithm %q", algorithm)
}
expected, err := hex.DecodeString(expectedHex)
if err != nil || len(expected) != sha256.Size {
return fmt.Errorf("invalid SHA-256 release digest %q", digest)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open release for checksum verification: %w", err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("failed to calculate release checksum: %w", err)
}
actual := hash.Sum(nil)
if !bytes.Equal(actual, expected) {
return fmt.Errorf("release checksum mismatch: expected %s, got %s", expectedHex, hex.EncodeToString(actual))
}
return nil
}

View File

@@ -0,0 +1,57 @@
package ghupdate
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyAssetChecksum(t *testing.T) {
path := filepath.Join(t.TempDir(), "asset")
if err := os.WriteFile(path, []byte("beszel release asset"), 0600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
digest string
wantErr string
}{
{
name: "valid",
digest: "sha256:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
},
{
name: "mismatch",
digest: "sha256:0316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "checksum mismatch",
},
{
name: "malformed",
digest: "sha256:not-a-checksum",
wantErr: "invalid SHA-256",
},
{
name: "missing",
wantErr: "invalid release digest",
},
{
name: "unsupported algorithm",
digest: "sha512:2316f86af2c3af2f0ef595ad5359cdd19b329d4829a5425460ca9ffbf92671ab",
wantErr: "unsupported",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := verifyAssetChecksum(path, tt.digest)
if tt.wantErr == "" && err != nil {
t.Fatalf("expected checksum to verify, got %v", err)
}
if tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

View File

@@ -46,18 +46,23 @@ func extractTarGz(srcPath, destDir string) error {
return err return err
} }
path, err := archivePath(destDir, header.Name)
if err != nil {
return err
}
if header.Typeflag == tar.TypeDir { if header.Typeflag == tar.TypeDir {
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil { if err := os.MkdirAll(path, 0755); err != nil {
return err return err
} }
continue continue
} }
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err return err
} }
outFile, err := os.Create(filepath.Join(destDir, header.Name)) outFile, err := os.Create(path)
if err != nil { if err != nil {
return err return err
} }
@@ -72,6 +77,14 @@ func extractTarGz(srcPath, destDir string) error {
return nil return nil
} }
// archivePath returns a path within destDir, rejecting path traversal entries.
func archivePath(destDir, name string) (string, error) {
if !filepath.IsLocal(name) {
return "", fmt.Errorf("invalid file path: %q", name)
}
return filepath.Join(destDir, name), nil
}
// extractZip extracts the zip archive at "src" to "dest". // extractZip extracts the zip archive at "src" to "dest".
// //
// Note that only dirs and regular files will be extracted. // Note that only dirs and regular files will be extracted.
@@ -84,9 +97,6 @@ func extractZip(src, dest string) error {
} }
defer zr.Close() defer zr.Close()
// normalize dest path to check later for Zip Slip
dest = filepath.Clean(dest) + string(os.PathSeparator)
for _, f := range zr.File { for _, f := range zr.File {
err := extractFile(f, dest) err := extractFile(f, dest)
if err != nil { if err != nil {
@@ -100,11 +110,9 @@ func extractZip(src, dest string) error {
// extractFile extracts the provided zipFile into "basePath/zipFileName" path, // extractFile extracts the provided zipFile into "basePath/zipFileName" path,
// creating all the necessary path directories. // creating all the necessary path directories.
func extractFile(zipFile *zip.File, basePath string) error { func extractFile(zipFile *zip.File, basePath string) error {
path := filepath.Join(basePath, zipFile.Name) path, err := archivePath(basePath, zipFile.Name)
if err != nil {
// check for Zip Slip return err
if !strings.HasPrefix(path, basePath) {
return fmt.Errorf("invalid file path: %s", path)
} }
r, err := zipFile.Open() r, err := zipFile.Open()

View File

@@ -135,21 +135,33 @@ func (p *updater) update() (updated bool, err error) {
return false, err return false, err
} }
releaseDir := filepath.Join(p.config.DataDir, ".beszel_update") if err := os.MkdirAll(p.config.DataDir, 0755); err != nil {
return false, fmt.Errorf("failed to create update data directory: %w", err)
}
releaseDir, err := os.MkdirTemp(p.config.DataDir, ".beszel_update-")
if err != nil {
return false, fmt.Errorf("failed to create update directory: %w", err)
}
defer os.RemoveAll(releaseDir) defer os.RemoveAll(releaseDir)
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name) ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
// download the release asset // download the release asset
assetPath := filepath.Join(releaseDir, asset.Name) assetPath, err := archivePath(releaseDir, asset.Name)
if err != nil {
return false, err
}
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil { if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err return false, err
} }
ColorPrint(ColorYellow, "Verifying checksum...")
if err := verifyAssetChecksum(assetPath, asset.Digest); err != nil {
return false, err
}
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name) ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name) extractDir := filepath.Join(releaseDir, "extracted")
defer os.RemoveAll(extractDir)
// Extract the archive (automatically detects format) // Extract the archive (automatically detects format)
if err := extract(assetPath, extractDir); err != nil { if err := extract(assetPath, extractDir); err != nil {

View File

@@ -1,6 +1,9 @@
package ghupdate package ghupdate
import ( import (
"archive/tar"
"compress/gzip"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -43,3 +46,59 @@ func TestExtractFailure(t *testing.T) {
t.Fatal("Expected Extract to fail due to missing tar.gz file") t.Fatal("Expected Extract to fail due to missing tar.gz file")
} }
} }
func TestArchivePath(t *testing.T) {
destDir := t.TempDir()
for _, name := range []string{
"",
"..",
filepath.Join("..", "file"),
filepath.Join("dir", "..", "..", "file"),
string(os.PathSeparator) + filepath.Join("tmp", "file"),
} {
if _, err := archivePath(destDir, name); err == nil {
t.Errorf("expected %q to be rejected", name)
}
}
name := filepath.Join("dir", "file")
if path, err := archivePath(destDir, name); err != nil || path != filepath.Join(destDir, name) {
t.Errorf("archivePath(%q) = %q, %v", name, path, err)
}
}
func TestExtractTarGzRejectsPathTraversal(t *testing.T) {
testDir := t.TempDir()
archivePath := filepath.Join(testDir, "malicious.tar.gz")
destDir := filepath.Join(testDir, "extract")
escapedPath := filepath.Join(testDir, "escaped")
archive, err := os.Create(archivePath)
if err != nil {
t.Fatal(err)
}
gz := gzip.NewWriter(archive)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "../escaped", Mode: 0600, Size: 1}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := archive.Close(); err != nil {
t.Fatal(err)
}
if err := extract(archivePath, destDir); err == nil {
t.Fatal("expected path traversal archive to be rejected")
}
if _, err := os.Stat(escapedPath); !os.IsNotExist(err) {
t.Fatalf("path traversal wrote %s", escapedPath)
}
}

View File

@@ -8,6 +8,7 @@ import (
type releaseAsset struct { type releaseAsset struct {
Name string `json:"name"` Name string `json:"name"`
DownloadUrl string `json:"browser_download_url"` DownloadUrl string `json:"browser_download_url"`
Digest string `json:"digest"`
Id int `json:"id"` Id int `json:"id"`
Size int `json:"size"` Size int `json:"size"`
} }

View File

@@ -317,6 +317,9 @@ func getRealIP(r *http.Request) string {
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip return ip
} }
if ip := r.Header.Get("X-Real-IP"); ip != "" {
return ip
}
if ip := r.Header.Get("X-Forwarded-For"); ip != "" { if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
// X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2" // X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2"
// Take the first one // Take the first one

View File

@@ -1796,6 +1796,24 @@ func TestGetRealIP(t *testing.T) {
remoteAddr: "127.0.0.1:12345", remoteAddr: "127.0.0.1:12345",
expectedIP: "192.168.1.8", expectedIP: "192.168.1.8",
}, },
{
name: "X-Real-IP header",
headers: map[string]string{"X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "X-Real-IP takes precedence over X-Forwarded-For",
headers: map[string]string{"X-Real-IP": "10.8.0.4", "X-Forwarded-For": "10.8.0.5"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "10.8.0.4",
},
{
name: "CF-Connecting-IP takes precedence over X-Real-IP",
headers: map[string]string{"CF-Connecting-IP": "1.2.3.4", "X-Real-IP": "10.8.0.4"},
remoteAddr: "172.21.0.1:12345",
expectedIP: "1.2.3.4",
},
} }
for _, tc := range testCases { for _, tc := range testCases {

View File

@@ -64,7 +64,7 @@ func createConfigTestFingerprint(app core.App, systemID, token, fingerprint stri
// TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios // TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios
func TestConfigSyncWithTokens(t *testing.T) { func TestConfigSyncWithTokens(t *testing.T) {
testHub, err := tests.NewTestHub() testHub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
defer testHub.Cleanup() defer testHub.Cleanup()

View File

@@ -0,0 +1,56 @@
//go:build testing
package systems
import (
"errors"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
)
// TestRunWithTimeout covers the guard added for issue #2041: the per-system SSH
// data exchange must never block the updater indefinitely on a dead connection.
func TestRunWithTimeout(t *testing.T) {
t.Run("returns the operation result when it completes before the timeout", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
wantErr := errors.New("boom")
onTimeoutCalled := false
retry, err := runWithTimeout(10*time.Second, func() (bool, error) {
return true, wantErr
}, func() { onTimeoutCalled = true })
assert.True(t, retry, "should return the operation's retry value")
assert.Equal(t, wantErr, err, "should return the operation's error")
assert.False(t, onTimeoutCalled, "onTimeout must not fire when the op completes")
})
})
t.Run("times out and tears down the connection when the op blocks", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// unblock simulates a half-open connection: the op is stuck reading a
// response that never arrives until the connection is torn down.
unblock := make(chan struct{})
onTimeoutCalled := false
start := time.Now()
retry, err := runWithTimeout(5*time.Second, func() (bool, error) {
<-unblock
return false, nil
}, func() {
onTimeoutCalled = true
close(unblock) // tearing down the connection releases the blocked read
})
assert.Equal(t, 5*time.Second, time.Since(start), "should return exactly at the timeout")
assert.True(t, retry, "a timeout should be retryable so the next tick re-dials")
assert.Error(t, err, "a timeout must surface an error so the system is set down")
assert.True(t, onTimeoutCalled, "onTimeout must fire so the dead connection is closed")
synctest.Wait() // ensure the released op goroutine exits cleanly
})
})
}

View File

@@ -388,6 +388,7 @@ func (sys *System) setDown(originalError error) error {
if originalError != nil { if originalError != nil {
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError) sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError)
} }
sys.detailsFetched.Store(false)
record.Set("status", down) record.Set("status", down)
return sys.manager.hub.SaveNoValidate(record) return sys.manager.hub.SaveNoValidate(record)
} }
@@ -531,11 +532,16 @@ func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.Servic
return result, err return result, err
} }
// FetchSmartDataFromAgent fetches SMART data from the agent // FetchSmartDataFromAgent fetches SMART data from the agent.
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) { func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
var result map[string]smart.SmartData if sys.agentVersion.LT(beszel.MinVersionAgentResponse) {
var data map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &data)
return smart.SmartDataResponse{Data: data}, err
}
var result smart.SmartDataResponse
err := sys.request(ctx, common.GetSmartData, nil, &result) err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err return result, err
} }
@@ -623,10 +629,17 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
continue continue
} }
retry, opErr := func() (bool, error) { // Bound the whole operation. A half-open TCP connection (a dead peer that
// never sends RST/FIN) or a wedged agent that accepts the session but
// never writes a response would otherwise block the read forever. Because
// StartUpdater runs update() synchronously on its ticker, that stalls the
// per-system updater indefinitely with no error and no re-dial until the
// hub is restarted (issue #2041). On timeout we tear down the connection
// so the blocked read unwinds and the system is re-dialed on the next tick.
retry, opErr := runWithTimeout(sshOperationTimeout, func() (bool, error) {
defer session.Close() defer session.Close()
return operation(session) return operation(session)
}() }, sys.closeSSHConnection)
if opErr == nil { if opErr == nil {
return nil return nil
@@ -645,6 +658,43 @@ func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation
return fmt.Errorf("ssh operation failed") return fmt.Errorf("ssh operation failed")
} }
// sshOperationTimeout bounds a single SSH data exchange (send request, read
// response, wait for the remote command to exit). It is more generous than the
// session-creation timeout to tolerate briefly slow agents, but is kept well
// under the collection interval so a stalled connection is detected and
// re-dialed within one cycle (see issue #2041).
const sshOperationTimeout = 20 * time.Second
// runWithTimeout runs op in a goroutine and returns its result, or, if op does
// not finish within timeout, calls onTimeout (used to tear down the connection
// so a blocked op can unwind) and returns a retryable timeout error. This
// guarantees the caller can never block indefinitely on a dead SSH connection.
func runWithTimeout(timeout time.Duration, op func() (bool, error), onTimeout func()) (retry bool, err error) {
type opResult struct {
retry bool
err error
}
// Buffered so the op goroutine never leaks even when we return on timeout.
done := make(chan opResult, 1)
go func() {
r, e := op()
done <- opResult{retry: r, err: e}
}()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case res := <-done:
return res.retry, res.err
case <-timer.C:
if onTimeout != nil {
onTimeout()
}
return true, fmt.Errorf("ssh operation timed out after %s", timeout)
}
}
// createSSHClient creates a new SSH client for the system // createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error { func (s *System) createSSHClient() error {
if s.manager.sshConfig == nil { if s.manager.sshConfig == nil {
@@ -660,7 +710,7 @@ func (s *System) createSSHClient() error {
host = net.JoinHostPort(host, s.Port) host = net.JoinHostPort(host, s.Port)
} }
var err error var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig) s.client, err = dialSSHWithKeepAlive(network, host, s.manager.sshConfig)
if err != nil { if err != nil {
return err return err
} }
@@ -669,6 +719,34 @@ func (s *System) createSSHClient() error {
return nil return nil
} }
// sshKeepAliveInterval is the TCP keep-alive idle interval for SSH connections
// to agents. Enabling OS-level keep-alives lets the hub eventually detect a
// dead peer on an otherwise idle connection instead of trusting it forever.
// This is a backstop for genuine network death; an application-level wedge
// (agent process hung while its kernel keeps ACKing) is caught by the
// per-operation timeout in runSSHOperation instead (see issue #2041).
const sshKeepAliveInterval = 30 * time.Second
// dialSSHWithKeepAlive dials an SSH connection like ssh.Dial, but enables TCP
// keep-alive on the underlying connection so half-open connections are
// eventually detected by the operating system.
func dialSSHWithKeepAlive(network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
dialer := net.Dialer{
Timeout: config.Timeout,
KeepAlive: sshKeepAliveInterval,
}
conn, err := dialer.Dial(network, addr)
if err != nil {
return nil, err
}
sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
_ = conn.Close()
return nil, err
}
return ssh.NewClient(sshConn, chans, reqs), nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging // createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues // in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) { func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {

View File

@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -17,13 +18,13 @@ type smartFetchState struct {
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database // FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
func (sys *System) FetchAndSaveSmartDevices() error { func (sys *System) FetchAndSaveSmartDevices() error {
smartData, err := sys.FetchSmartDataFromAgent() response, err := sys.FetchSmartDataFromAgent()
if err != nil { if err != nil {
sys.recordSmartFetchResult(err, 0) sys.recordSmartFetchResult(err, 0)
return err return err
} }
err = sys.saveSmartDevices(smartData) err = sys.saveSmartDevices(response.Data, response.Complete)
sys.recordSmartFetchResult(err, len(smartData)) sys.recordSmartFetchResult(err, len(response.Data))
return err return err
} }
@@ -61,8 +62,9 @@ func (sys *System) smartFetchInterval() time.Duration {
return time.Hour return time.Hour
} }
// saveSmartDevices saves SMART device data to the smart_devices collection // saveSmartDevices saves SMART device data and, after a complete refresh,
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error { // removes rows for devices that are no longer reported.
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData, complete bool) error {
if len(smartData) == 0 { if len(smartData) == 0 {
return nil return nil
} }
@@ -73,20 +75,49 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
return err return err
} }
for deviceKey, device := range smartData { currentIDs := make(map[string]struct{}, len(smartData))
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil { for deviceKey := range smartData {
return err currentIDs[makeStableHashId(sys.Id, deviceKey)] = struct{}{}
}
} }
return nil err = hub.RunInTransaction(func(txApp core.App) error {
if complete {
existing, err := txApp.FindRecordsByFilter(
collection,
"system = {:system}",
"",
0,
0,
dbx.Params{"system": sys.Id},
)
if err != nil {
return err
}
for _, record := range existing {
if _, ok := currentIDs[record.Id]; ok {
continue
}
if err := txApp.Delete(record); err != nil {
return err
}
}
}
for deviceKey, device := range smartData {
if err := sys.upsertSmartDeviceRecord(txApp, collection, deviceKey, device); err != nil {
return err
}
}
return nil
})
return err
} }
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error { func (sys *System) upsertSmartDeviceRecord(app core.App, collection *core.Collection, deviceKey string, device smart.SmartData) error {
hub := sys.manager.hub
recordID := makeStableHashId(sys.Id, deviceKey) recordID := makeStableHashId(sys.Id, deviceKey)
record, err := hub.FindRecordById(collection, recordID) record, err := app.FindRecordById(collection, recordID)
if err != nil { if err != nil {
if !errors.Is(err, sql.ErrNoRows) { if !errors.Is(err, sql.ErrNoRows) {
return err return err
@@ -114,7 +145,7 @@ func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKe
record.Set("cycles", powerCycles) record.Set("cycles", powerCycles)
record.Set("attributes", device.Attributes) record.Set("attributes", device.Attributes)
return hub.SaveNoValidate(record) return app.SaveNoValidate(record)
} }
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes // extractPowerMetrics extracts power on hours and power cycles from SMART attributes

View File

@@ -7,10 +7,53 @@ import (
"testing" "testing"
"time" "time"
"github.com/henrygd/beszel/internal/entities/smart"
esystem "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/hub/expirymap"
_ "github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/pocketbase/core"
pbtests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
) )
// stubHub implements hubLike using a plain pocketbase test app, so
// smart-device DB tests can run in-package without an import cycle to
// internal/hub (which imports this package).
type stubHub struct{ core.App }
func (stubHub) GetSSHKey(dataDir string) (ssh.Signer, error) { return nil, nil }
func (stubHub) HandleSystemAlerts(systemRecord *core.Record, data *esystem.CombinedData) error {
return nil
}
func (stubHub) HandleStatusAlerts(status string, systemRecord *core.Record) error { return nil }
func (stubHub) CancelPendingStatusAlerts(systemID string) {}
// newTestSystemWithHub creates a System backed by a real (temp) database, along
// with a matching "systems" record, for tests that need to exercise DB reads/writes.
func newTestSystemWithHub(t *testing.T) (*System, *pbtests.TestApp) {
t.Helper()
testApp, err := pbtests.NewTestApp(t.TempDir())
require.NoError(t, err)
t.Cleanup(testApp.Cleanup)
sm := &SystemManager{hub: stubHub{testApp}, smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner)
col, err := testApp.FindCachedCollectionByNameOrId("systems")
require.NoError(t, err)
systemRecord := core.NewRecord(col)
systemRecord.Set("name", "test-system")
systemRecord.Set("host", "127.0.0.1")
systemRecord.Set("port", "45876")
require.NoError(t, testApp.SaveNoValidate(systemRecord))
sys := &System{Id: systemRecord.Id, manager: sm}
return sys, testApp
}
func TestRecordSmartFetchResult(t *testing.T) { func TestRecordSmartFetchResult(t *testing.T) {
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)} sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
t.Cleanup(sm.smartFetchMap.StopCleaner) t.Cleanup(sm.smartFetchMap.StopCleaner)
@@ -92,3 +135,95 @@ func TestResetFailedSmartFetchState(t *testing.T) {
_, ok = sm.smartFetchMap.GetOk("system-1") _, ok = sm.smartFetchMap.GetOk("system-1")
assert.True(t, ok, "expected successful smart fetch state to be preserved") assert.True(t, ok, "expected successful smart fetch state to be preserved")
} }
// countSmartDeviceRecords returns the number of smart_devices rows for the given system.
func countSmartDeviceRecords(t *testing.T, app core.App, systemID string) []*core.Record {
t.Helper()
records, err := app.FindAllRecords("smart_devices", nil)
require.NoError(t, err)
var forSystem []*core.Record
for _, r := range records {
if r.GetString("system") == systemID {
forSystem = append(forSystem, r)
}
}
return forSystem
}
func TestSaveSmartDevices_RemovesStaleDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
// first fetch reports two devices: sda (serial AAA) and sdb (serial BBB)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb", ModelName: "Disk B"},
}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 2, "expected both devices to be saved")
var recordA *core.Record
for _, r := range records {
if r.GetString("serial") == "AAA" {
recordA = r
}
}
require.NotNil(t, recordA, "expected to find device AAA")
originalID := recordA.Id
deleteEvents := 0
testApp.OnRecordAfterDeleteSuccess("smart_devices").BindFunc(func(e *core.RecordEvent) error {
deleteEvents++
return e.Next()
})
// A complete refresh confirms that BBB is gone, so remove it through
// PocketBase and notify realtime subscribers.
err = sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", ModelName: "Disk A", Temperature: 42},
}, true)
require.NoError(t, err)
records = countSmartDeviceRecords(t, testApp, sys.Id)
require.Len(t, records, 1, "expected stale device BBB to be removed")
assert.Equal(t, "AAA", records[0].GetString("serial"))
assert.Equal(t, originalID, records[0].Id, "expected existing device to be updated in place, not recreated")
assert.EqualValues(t, 42, records[0].GetInt("temp"))
assert.Equal(t, 1, deleteEvents, "expected PocketBase delete hooks to run")
}
func TestSaveSmartDevices_IncompleteDataDoesNotRemoveDevices(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
"BBB": {SerialNumber: "BBB", DiskName: "sdb"},
}, true))
// AAA was collected but BBB failed. The response is useful for updating AAA,
// but it is not authoritative enough to remove BBB.
require.NoError(t, sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda", Temperature: 42},
}, false))
assert.Len(t, countSmartDeviceRecords(t, testApp, sys.Id), 2)
recordA, err := testApp.FindRecordById("smart_devices", makeStableHashId(sys.Id, "AAA"))
require.NoError(t, err)
assert.EqualValues(t, 42, recordA.GetInt("temp"))
}
func TestSaveSmartDevices_EmptyDataIsNoop(t *testing.T) {
sys, testApp := newTestSystemWithHub(t)
err := sys.saveSmartDevices(map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA", DiskName: "sda"},
}, true)
require.NoError(t, err)
err = sys.saveSmartDevices(map[string]smart.SmartData{}, true)
require.NoError(t, err)
records := countSmartDeviceRecords(t, testApp, sys.Id)
assert.Len(t, records, 1, "empty fetch result should not delete existing devices")
}

View File

@@ -88,15 +88,23 @@ func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketA
*d = *resp.String *d = *resp.String
return nil return nil
case common.GetSmartData: case common.GetSmartData:
d, ok := dest.(*map[string]smart.SmartData) switch d := dest.(type) {
if !ok { case *map[string]smart.SmartData:
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case *smart.SmartDataResponse:
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
d.Data = resp.SmartData
d.Complete = resp.SmartComplete
return nil
default:
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest) return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
} }
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case common.GetSystemdInfo: case common.GetSystemdInfo:
d, ok := dest.(*systemd.ServiceDetails) d, ok := dest.(*systemd.ServiceDetails)
if !ok { if !ok {

View File

@@ -0,0 +1,33 @@
package transport
import (
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUnmarshalSmartDataResponse(t *testing.T) {
for _, test := range []struct {
name string
complete bool
}{
{name: "complete response", complete: true},
{name: "older agent defaults to incomplete", complete: false},
} {
t.Run(test.name, func(t *testing.T) {
response := common.AgentResponse{
SmartData: map[string]smart.SmartData{
"AAA": {SerialNumber: "AAA"},
},
SmartComplete: test.complete,
}
var result smart.SmartDataResponse
require.NoError(t, UnmarshalResponse(response, common.GetSmartData, &result))
assert.Equal(t, test.complete, result.Complete)
assert.Equal(t, "AAA", result.Data["AAA"].SerialNumber)
})
}
}

View File

@@ -3,7 +3,7 @@ package records
import ( import (
"encoding/json" "encoding/json"
"log" "log/slog"
"math" "math"
"time" "time"
@@ -68,15 +68,18 @@ func (rm *RecordManager) CreateLongerRecords() {
}, },
} }
// wrap the operations in a transaction // wrap the operations in a transaction
// Pocketbase cron does not handle errors, log them here.
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
var err error var err error
collections := [2]*core.Collection{} collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats") collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil { if err != nil {
slog.Error("Error finding cached collection using system stats:", "err", err)
return err return err
} }
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats") collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil { if err != nil {
slog.Error("Error finding cached collection using container stats:", "err", err)
return err return err
} }
var systems RecordIds var systems RecordIds
@@ -142,7 +145,7 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds)) longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
} }
if err := txApp.SaveNoValidate(longerRecord); err != nil { if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err) slog.Error("failed to save longer record", "err", err)
} }
} }
} }
@@ -188,6 +191,8 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
// accumulate cpu breakdown [user, system, iowait, steal, idle] // accumulate cpu breakdown [user, system, iowait, steal, idle]
var cpuBreakdownSums []float64 var cpuBreakdownSums []float64
tempCount := float64(0) tempCount := float64(0)
var fanSums map[string]uint64
fanCount := uint64(0)
// Accumulate totals // Accumulate totals
for i := range records { for i := range records {
@@ -279,6 +284,17 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
} }
} }
// Accumulate fan speeds
if stats.Fans != nil {
if fanSums == nil {
fanSums = make(map[string]uint64, len(stats.Fans))
}
fanCount++
for key, value := range stats.Fans {
fanSums[key] += uint64(value)
}
}
// Accumulate extra filesystem stats // Accumulate extra filesystem stats
if stats.ExtraFs != nil { if stats.ExtraFs != nil {
if sum.ExtraFs == nil { if sum.ExtraFs == nil {
@@ -384,6 +400,14 @@ func AverageSystemStatsSlice(records []system.Stats) system.Stats {
} }
} }
// Average fan speeds
if fanSums != nil && fanCount > 0 {
sum.Fans = make(map[string]uint16, len(fanSums))
for key, value := range fanSums {
sum.Fans[key] = uint16(value / fanCount)
}
}
// Average extra filesystem stats // Average extra filesystem stats
if sum.ExtraFs != nil { if sum.ExtraFs != nil {
for key := range sum.ExtraFs { for key := range sum.ExtraFs {

View File

@@ -291,6 +291,28 @@ func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
assert.Equal(t, 80.0, result.Temperatures["gpu"]) assert.Equal(t, 80.0, result.Temperatures["gpu"])
} }
// Tests that fan speeds are averaged and records without fan data are excluded.
func TestAverageSystemStatsSlice_Fans(t *testing.T) {
input := []system.Stats{
{
Fans: map[string]uint16{"cpu": 60_000, "case": 1_000},
},
{
Fans: map[string]uint16{"cpu": 50_000, "case": 2_000},
},
{
// No fan data - should not affect fan averaging
Cpu: 30.0,
},
}
result := records.AverageSystemStatsSlice(input)
require.NotNil(t, result.Fans)
assert.Equal(t, uint16(55_000), result.Fans["cpu"])
assert.Equal(t, uint16(1_500), result.Fans["case"])
}
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) { func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
input := []system.Stats{ input := []system.Stats{
{ {

View File

@@ -12,6 +12,7 @@ import (
// Delete old records // Delete old records
func (rm *RecordManager) DeleteOldRecords() { func (rm *RecordManager) DeleteOldRecords() {
// Pocketbase cron does not handle errors, log them here.
rm.app.RunInTransaction(func(txApp core.App) error { rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp) err := deleteOldSystemStats(txApp)
if err != nil { if err != nil {

View File

@@ -104,7 +104,7 @@ export default function LineChartDefault({
isAnimationActive={false} isAnimationActive={false}
// stackId={dataPoint.stackId} // stackId={dataPoint.stackId}
order={dataPoint.order || i} order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true} activeDot={dataPoint.activeDot ?? true}
/> />
) )
}) })

View File

@@ -17,7 +17,7 @@ import { toast } from "../ui/use-toast"
import { OtpInputForm } from "./otp-forms" import { OtpInputForm } from "./otp-forms"
const honeypot = v.literal("") const honeypot = v.literal("")
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`)) const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
const passwordSchema = v.pipe( const passwordSchema = v.pipe(
v.string(), v.string(),
v.minLength(8, t`Password must be at least 8 characters.`), v.minLength(8, t`Password must be at least 8 characters.`),
@@ -175,8 +175,8 @@ export function UserAuthForm({
*/ */
function redirectToOauthProvider(provider: AuthProviderInfo) { function redirectToOauthProvider(provider: AuthProviderInfo) {
const url = new URL(provider.authURL) const url = new URL(provider.authURL)
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`) url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
sessionStorage.setItem("provider", JSON.stringify(provider)) localStorage.setItem("provider", JSON.stringify(provider))
window.location.href = url.toString() window.location.href = url.toString()
} }
@@ -186,12 +186,13 @@ export function UserAuthForm({
const code = params.get("code") const code = params.get("code")
if (code) { if (code) {
const state = params.get("state") const state = params.get("state")
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}") const provider: AuthProviderInfo = JSON.parse(localStorage.getItem("provider") ?? "{}")
localStorage.removeItem("provider")
window.history.replaceState({}, "", window.location.pathname)
if (!state || provider.state !== state) { if (!state || provider.state !== state) {
showLoginFaliedToast() showLoginFaliedToast()
} else { } else {
setIsOauthLoading(true) setIsOauthLoading(true)
window.history.replaceState({}, "", window.location.pathname)
pb.collection("users") pb.collection("users")
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`) .authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
.then(() => $authenticated.set(pb.authStore.isValid)) .then(() => $authenticated.set(pb.authStore.isValid))
@@ -285,7 +286,7 @@ export function UserAuthForm({
{errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>} {errors?.passwordConfirm && <p className="px-1 text-xs text-red-600">{errors.passwordConfirm}</p>}
</div> </div>
)} )}
<div className="sr-only"> <div className="sr-only" aria-hidden="true">
{/* honeypot */} {/* honeypot */}
<label htmlFor="website">Website</label> <label htmlFor="website">Website</label>
<input <input
@@ -293,6 +294,7 @@ export function UserAuthForm({
type="text" type="text"
name="website" name="website"
tabIndex={-1} tabIndex={-1}
readOnly
autoComplete="off" autoComplete="off"
data-1p-ignore data-1p-ignore
data-lpignore="true" data-lpignore="true"

View File

@@ -24,7 +24,7 @@ interface ShoutrrrUrlCardProps {
} }
const NotificationSchema = v.object({ const NotificationSchema = v.object({
emails: v.array(v.pipe(v.string(), v.email())), emails: v.array(v.pipe(v.string(), v.rfcEmail())),
webhooks: v.array(v.pipe(v.string(), v.url())), webhooks: v.array(v.pipe(v.string(), v.url())),
}) })

View File

@@ -139,6 +139,7 @@ const SectionUniversalToken = memo(() => {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false) const [checked, setChecked] = useState(false)
const [isPermanent, setIsPermanent] = useState(false) const [isPermanent, setIsPermanent] = useState(false)
const publicKey = $publicKey.get()
async function updateToken(enable: number = -1, permanent: number = -1) { async function updateToken(enable: number = -1, permanent: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state // enable: 0 for disable, 1 for enable, -1 (unset) for get current state
@@ -191,6 +192,25 @@ const SectionUniversalToken = memo(() => {
<ActionsButtonUniversalToken token={token} checked={checked} /> <ActionsButtonUniversalToken token={token} checked={checked} />
</div> </div>
{checked && (
<div className="flex items-center gap-4 min-w-0 border-t pt-3">
<span className="text-sm text-muted-foreground whitespace-nowrap">
<Trans>Public key</Trans>
</span>
<div className="min-w-0 flex-1 truncate">
<span className="text-sm text-primary font-mono">{publicKey}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(publicKey)}
title={t`Copy public key`}
>
<CopyIcon className="w-4" />
</Button>
</div>
)}
{checked && ( {checked && (
<div className="border-t pt-3"> <div className="border-t pt-3">
<div className="text-sm font-medium"> <div className="text-sm font-medium">

View File

@@ -9,7 +9,7 @@ import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts" import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts" import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts" import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts" import { TemperatureChart, FanChart, BatteryChart } from "./system/charts/sensor-charts"
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts" import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables" import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
import { LoadAverageChart } from "./system/charts/load-average-chart" import { LoadAverageChart } from "./system/charts/load-average-chart"
@@ -123,6 +123,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<TemperatureChart {...coreProps} /> <TemperatureChart {...coreProps} />
<FanChart {...coreProps} />
<BatteryChart {...coreProps} /> <BatteryChart {...coreProps} />
{hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />} {hasGpuPowerData && <GpuPowerChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />}
@@ -188,6 +190,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} /> <LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
<BandwidthChart {...coreProps} systemStats={systemStats} /> <BandwidthChart {...coreProps} systemStats={systemStats} />
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} /> <TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
<FanChart {...coreProps} />
<BatteryChart {...coreProps} /> <BatteryChart {...coreProps} />
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} /> <SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>} {pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}

View File

@@ -108,7 +108,7 @@ export function ContainerMemoryChart({
dataPoints={dataPoints} dataPoints={dataPoints}
tickFormatter={(val) => { tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true) const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}` return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}} }}
contentFormatter={(item) => { contentFormatter={(item) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true) const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro" import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart" import AreaChartDefault from "@/components/charts/area-chart"
import { batteryStateTranslations } from "@/lib/i18n" import { batteryStateTranslations } from "@/lib/i18n"
import { $temperatureFilter, $userSettings } from "@/lib/stores" import { $fanFilter, $temperatureFilter, $userSettings } from "@/lib/stores"
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils" import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types" import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar } from "../chart-card" import { ChartCard, FilterBar } from "../chart-card"
@@ -120,7 +120,8 @@ export function TemperatureChart({
label: key, label: key,
dataKey: dataKeys[key], dataKey: dataKeys[key],
color: colorMap[key], color: colorMap[key],
opacity: strokeOpacity, strokeOpacity,
activeDot: !filtered,
} }
}) })
}, [sortedKeys, filter, dataKeys, colorMap]) }, [sortedKeys, filter, dataKeys, colorMap])
@@ -134,7 +135,7 @@ export function TemperatureChart({
// label: `Test ${++i}`, // label: `Test ${++i}`,
// dataKey: () => 0, // dataKey: () => 0,
// color: "red", // color: "red",
// opacity: 1, // strokeOpacity: 1,
// }) // })
// } // }
// } // }
@@ -202,6 +203,106 @@ export function TemperatureChart({
return `${decimalString(value)} ${unit}` return `${decimalString(value)} ${unit}`
}} }}
dataPoints={dataPoints} dataPoints={dataPoints}
filter={filter}
></LineChartDefault>
</ChartCard>
</div>
)
}
export function FanChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const showFanChart = chartData.systemStats.at(-1)?.stats.f
const filter = useStore($fanFilter)
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Stable key derived from current sensor names (sorted) so the memo only
// recomputes when the set of fans changes — not on every data point.
let sensorNamesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const f = chartData.systemStats[i].stats?.f
if (f) {
sensorNamesKey = Object.keys(f).sort().join("\0")
break
}
}
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
const stats = statsRef.current
const sums = {} as Record<string, number>
for (const data of stats) {
const f = data.stats?.f
if (!f) continue
for (const key of Object.keys(f)) {
sums[key] = (sums[key] ?? 0) + f[key]
}
}
const sorted = Object.keys(sums).sort((a, b) => sums[b] - sums[a])
const colorMap = {} as Record<string, string>
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
for (let i = 0; i < sorted.length; i++) {
const key = sorted[i]
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.f?.[key]
}
return { colorMap, dataKeys, sortedKeys: sorted }
}, [sensorNamesKey])
const dataPoints = useMemo(() => {
return sortedKeys.map((key) => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
strokeOpacity,
activeDot: !filtered,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
if (!showFanChart) {
return null
}
const legend = dataPoints.length < 12
return (
<div className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Fans`}
description={t`System fan speeds (RPM)`}
cornerEl={<FilterBar store={$fanFilter} />}
legend={legend}
>
<LineChartDefault
chartData={chartData}
itemSorter={(a, b) => b.value - a.value}
domain={["auto", "auto"]}
legend={legend}
tickFormatter={(val) => `${toFixedFloat(val, 0)}`}
contentFormatter={(item) => `${decimalString(item.value, 0)} RPM`}
dataPoints={dataPoints}
filter={filter}
></LineChartDefault> ></LineChartDefault>
</ChartCard> </ChartCard>
</div> </div>

View File

@@ -470,7 +470,7 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
STATUS_COLORS.down STATUS_COLORS.down
) )
return ( return (
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full"> <div className="flex gap-2 items-center tabular-nums tracking-tight w-full min-w-0">
<span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span> <span className="min-w-8 shrink-0">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden"> <span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
<span className={meterClass} style={{ width: `${val}%` }}></span> <span className={meterClass} style={{ width: `${val}%` }}></span>

View File

@@ -477,7 +477,7 @@ const SystemCard = memo(
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="text-sm px-5 pt-3.5 pb-4"> <CardContent className="text-sm px-5 pt-3.5 pb-4">
<div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) 1fr" }}> <div className="grid gap-2.5" style={{ gridTemplateColumns: "24px minmax(80px, max-content) minmax(0, 1fr)" }}>
{table.getAllColumns().map((column) => { {table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id) const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
@@ -496,7 +496,7 @@ const SystemCard = memo(
<div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3"> <div key={`${column.id}-label`} className="flex items-center text-muted-foreground pr-3">
{name()}: {name()}:
</div> </div>
<div key={`${column.id}-value`} className="flex items-center"> <div key={`${column.id}-value`} className="flex items-center min-w-0">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div> </div>
</> </>

View File

@@ -64,6 +64,9 @@ export const $containerFilter = atom("")
/** Temperature chart filter */ /** Temperature chart filter */
export const $temperatureFilter = atom("") export const $temperatureFilter = atom("")
/** Fan-speed chart filter */
export const $fanFilter = atom("")
/** Fallback copy to clipboard dialog content */ /** Fallback copy to clipboard dialog content */
export const $copyContent = atom("") export const $copyContent = atom("")

View File

@@ -143,6 +143,8 @@ export interface SystemStats {
bm?: [number, number] bm?: [number, number]
/** temperatures */ /** temperatures */
t?: Record<string, number> t?: Record<string, number>
/** fan speeds (RPM) — keyed by `<chip>_<label-or-fan-idx>` */
f?: Record<string, number>
/** extra filesystems */ /** extra filesystems */
efs?: Record<string, ExtraFsStats> efs?: Record<string, ExtraFsStats>
/** GPU data */ /** GPU data */
@@ -208,7 +210,7 @@ interface ContainerStats {
n: string n: string
/** cpu percent */ /** cpu percent */
c: number c: number
/** memory used (gb) */ /** memory used (mb) */
m: number m: number
// network sent (mb) // network sent (mb)
ns?: number ns?: number

View File

@@ -16,7 +16,7 @@ It has a friendly web interface, simple configuration, and is ready to use out o
- **Lightweight**: Smaller and less resource-intensive than leading solutions. - **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Simple**: Easy setup with little manual configuration required. - **Simple**: Easy setup with little manual configuration required.
- **Docker stats**: Tracks CPU, memory, and network usage history for each container. - **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, load average, and status. - **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, fan speed, load average, and status.
- **Multi-user**: Users manage their own systems. Admins can share systems across users. - **Multi-user**: Users manage their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled. - **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automatic backups**: Save to and restore from disk or S3-compatible storage. - **Automatic backups**: Save to and restore from disk or S3-compatible storage.
@@ -48,6 +48,7 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **Network usage** - Host system and containers. - **Network usage** - Host system and containers.
- **Load average** - Host system. - **Load average** - Host system.
- **Temperature** - Host system sensors. - **Temperature** - Host system sensors.
- **Fan speed** - Host system sensors (Linux, via `/sys/class/hwmon`).
- **GPU usage / power draw** - Nvidia, AMD, and Intel. - **GPU usage / power draw** - Nvidia, AMD, and Intel.
- **Battery** - Host system battery charge. - **Battery** - Host system battery charge.
- **Containers** - Status and metrics of all running Docker / Podman containers. - **Containers** - Status and metrics of all running Docker / Podman containers.

View File

@@ -1,18 +1,24 @@
# Specialized images available for Nvidia / Intel GPUs
#
# Docs: https://beszel.dev/guide/agent-installation
# Env vars: https://beszel.dev/guide/environment-variables
services: services:
beszel-agent: beszel-agent:
image: 'henrygd/beszel-agent' #Or henrygd/beszel-agent-nvidia image: henrygd/beszel-agent
container_name: 'beszel-agent' container_name: beszel-agent
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
# Only when using henrygd/beszel-agent-nvidia
# runtime: nvidia
volumes: volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems # monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro # - /mnt/disk1/.beszel:/extra-filesystems/disk1:ro
environment: environment:
PORT: 45876 LISTEN: 45876
KEY: 'ssh-ed25519 YOUR_PUBLIC_KEY' KEY: "<public key>"
# Only when using henrygd/beszel-agent-nvidia HUB_URL: "<hub url>"
# NVIDIA_VISIBLE_DEVICES: all TOKEN: "<token>"
# NVIDIA_DRIVER_CAPABILITIES: compute,video,utility # healthcheck:
# test: ['CMD', '/agent', 'health']
# interval: 120s

View File

@@ -1,9 +1,17 @@
# Docs: https://beszel.dev/guide/hub-installation
# Env vars: https://beszel.dev/guide/environment-variables
services: services:
beszel: beszel:
image: 'henrygd/beszel' image: "henrygd/beszel"
container_name: 'beszel' container_name: "beszel"
restart: unless-stopped restart: unless-stopped
ports: ports:
- '8090:8090' - "8090:8090"
volumes: volumes:
- ./beszel_data:/beszel_data - ./beszel_data:/beszel_data
# healthcheck:
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
# interval: 120s
# start_period: 10s
# timeout: 5s

View File

@@ -1,26 +1,38 @@
# Docs: https://beszel.dev/guide/getting-started
# Env vars: https://beszel.dev/guide/environment-variables
services: services:
beszel: beszel:
image: 'henrygd/beszel' image: henrygd/beszel:latest
container_name: 'beszel' container_name: beszel
restart: unless-stopped restart: unless-stopped
environment:
APP_URL: http://localhost:8090
ports: ports:
- '8090:8090' - 8090:8090
volumes: volumes:
- ./beszel_data:/beszel_data - ./beszel_data:/beszel_data
extra_hosts: - ./beszel_socket:/beszel_socket
- 'host.docker.internal:host-gateway' # healthcheck:
# test: ['CMD', '/beszel', 'health', '--url', 'http://localhost:8090']
# interval: 120s
# start_period: 10s
# timeout: 5s
beszel-agent: beszel-agent:
image: 'henrygd/beszel-agent' #Add -nvidia for nvidia gpus image: henrygd/beszel-agent:latest
container_name: 'beszel-agent' container_name: beszel-agent
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
# runtime: nvidia # when using beszel-agent-nvidia
volumes: volumes:
- ./beszel_agent_data:/var/lib/beszel-agent
- ./beszel_socket:/beszel_socket
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
environment: environment:
PORT: 45876 LISTEN: /beszel_socket/beszel.sock
KEY: '...' HUB_URL: http://localhost:8090
# FILESYSTEM: /dev/sda1 # set to the correct filesystem for disk I/O stats TOKEN: <token>
# NVIDIA_VISIBLE_DEVICES: all # when using beszel-agent-nvidia KEY: "<key>"
# NVIDIA_DRIVER_CAPABILITIES: utility # when using beszel-agent-nvidia # healthcheck:
# test: ['CMD', '/agent', 'health']
# interval: 120s

View File

@@ -5,7 +5,7 @@ is_alpine() {
} }
is_openwrt() { is_openwrt() {
grep -qi "OpenWrt" /etc/os-release [ -f /etc/os-release ] && grep -qi "OpenWrt" /etc/os-release
} }
is_freebsd() { is_freebsd() {
@@ -16,6 +16,10 @@ is_opnsense() {
[ -f /usr/local/sbin/opnsense-version ] || [ -f /usr/local/etc/opnsense-version ] || [ -f /etc/opnsense-release ] [ -f /usr/local/sbin/opnsense-version ] || [ -f /usr/local/etc/opnsense-version ] || [ -f /etc/opnsense-release ]
} }
is_pfsense() {
[ -f /etc/rc.bootup ] && grep -qi "pfSense" /etc/rc.bootup
}
is_glibc() { is_glibc() {
# Prefer glibc-enabled agent (NVML via purego) on linux/amd64 glibc systems. # Prefer glibc-enabled agent (NVML via purego) on linux/amd64 glibc systems.
# Check common dynamic loader paths first (fast + reliable). # Check common dynamic loader paths first (fast + reliable).
@@ -183,6 +187,21 @@ run_rc_command "$1"
EOF EOF
} }
# Generate the boot hook used by the firewall appliances, neither of which
# starts an enabled rc.d service at boot. The script body is identical for
# both; only the install path differs (see the boot hook installation below).
# The running-check guards against a double start, since pfSense may also run
# the hook again during network events.
generate_appliance_boot_script() {
cat <<'EOF'
#!/bin/sh
if ! /usr/sbin/service beszel-agent status >/dev/null 2>&1; then
/usr/sbin/service beszel-agent onestart
fi
EOF
}
# Detect system architecture # Detect system architecture
detect_architecture() { detect_architecture() {
local arch=$(uname -m) local arch=$(uname -m)
@@ -437,14 +456,19 @@ if [ "$UNINSTALL" = true ]; then
echo "Removing the FreeBSD service files..." echo "Removing the FreeBSD service files..."
rm -f /usr/local/etc/rc.d/beszel-agent rm -f /usr/local/etc/rc.d/beszel-agent
rm -f /usr/local/etc/rc.d/beszel-agent-start.sh
# Remove the OPNsense boot hook if it exists
rm -f /usr/local/etc/rc.syshook.d/start/99-beszel-agent
# Remove the daily update cron job if it exists # Remove the daily update cron job if it exists
echo "Removing the daily update cron job..." echo "Removing the daily update cron job..."
rm -f /etc/cron.d/beszel-agent rm -f /etc/cron.d/beszel-agent
# Remove log files # Remove log files. The rc script derives its logfile from $name
# (beszel_agent), not from the script filename (beszel-agent).
echo "Removing log files..." echo "Removing log files..."
rm -f /var/log/beszel-agent.log rm -f /var/log/beszel_agent.log
# Remove env file and directories # Remove env file and directories
echo "Removing environment configuration file..." echo "Removing environment configuration file..."
@@ -595,8 +619,8 @@ elif is_openwrt; then
fi fi
elif is_freebsd; then elif is_freebsd; then
if is_opnsense; then if is_opnsense || is_pfsense; then
echo "OPNsense detected: skipping user creation (using daemon user instead)" echo "Firewall appliance detected: skipping user creation (using daemon user instead)"
AGENT_USER="daemon" AGENT_USER="daemon"
else else
if ! id -u beszel >/dev/null 2>&1; then if ! id -u beszel >/dev/null 2>&1; then
@@ -716,7 +740,7 @@ if [ -f "$BIN_PATH" ]; then
fi fi
mv beszel-agent "$BIN_PATH" mv beszel-agent "$BIN_PATH"
chown beszel:beszel "$BIN_PATH" chown "${AGENT_USER}:${AGENT_USER}" "$BIN_PATH"
chmod 755 "$BIN_PATH" chmod 755 "$BIN_PATH"
# Set SELinux context if needed # Set SELinux context if needed
@@ -779,7 +803,7 @@ EOF
# Create log files with proper permissions # Create log files with proper permissions
touch /var/log/beszel-agent.log /var/log/beszel-agent.err touch /var/log/beszel-agent.log /var/log/beszel-agent.err
chown beszel:beszel /var/log/beszel-agent.log /var/log/beszel-agent.err chown "${AGENT_USER}:${AGENT_USER}" /var/log/beszel-agent.log /var/log/beszel-agent.err
# Start the service # Start the service
rc-service beszel-agent restart rc-service beszel-agent restart
@@ -908,11 +932,11 @@ KEY="$KEY"
TOKEN=$TOKEN TOKEN=$TOKEN
HUB_URL=$HUB_URL HUB_URL=$HUB_URL
EOF EOF
chmod 640 "$AGENT_DIR/env"
chown "root:${AGENT_USER}" "$AGENT_DIR/env"
else else
echo "FreeBSD environment file already exists. Skipping creation." echo "FreeBSD environment file already exists. Skipping creation."
fi fi
chmod 640 "$AGENT_DIR/env"
chown "root:${AGENT_USER}" "$AGENT_DIR/env"
# Create the rc service file if it doesn't exist # Create the rc service file if it doesn't exist
if [ ! -f /usr/local/etc/rc.d/beszel-agent ]; then if [ ! -f /usr/local/etc/rc.d/beszel-agent ]; then
@@ -924,10 +948,43 @@ EOF
echo "FreeBSD rc service file already exists. Skipping creation." echo "FreeBSD rc service file already exists. Skipping creation."
fi fi
if is_pfsense; then
echo "Creating pfSense boot script..."
generate_appliance_boot_script > /usr/local/etc/rc.d/beszel-agent-start.sh
chmod 755 /usr/local/etc/rc.d/beszel-agent-start.sh
elif is_opnsense; then
# OPNsense does not execute /usr/local/etc/rc.d/*.sh at boot, so the
# pfSense hook above would never run. Install the same script as a syshook.
echo "Creating OPNsense boot script..."
mkdir -p /usr/local/etc/rc.syshook.d/start
generate_appliance_boot_script > /usr/local/etc/rc.syshook.d/start/99-beszel-agent
chmod 755 /usr/local/etc/rc.syshook.d/start/99-beszel-agent
fi
# Enable and start the service # Enable and start the service
echo "Enabling and starting the agent service..." echo "Enabling and starting the agent service..."
sysrc beszel_agent_enable="YES" sysrc beszel_agent_enable="YES"
sysrc beszel_agent_user="${AGENT_USER}" sysrc beszel_agent_user="${AGENT_USER}"
# sysrc writes to /etc/rc.conf, but rc.subr sources /etc/rc.conf.d/beszel_agent
# afterwards, so a stale or third-party file there silently overrides the value
# we just set. The service then refuses to start, and rc.subr's own error points
# at /etc/rc.conf, which looks correct. Verify the flag actually took effect.
# An empty value means this rc.subr does not support "rcvar"; skip the check.
rcvar_value=$(service beszel-agent rcvar 2>/dev/null | sed -n 's/^beszel_agent_enable="\(.*\)"$/\1/p')
if [ -n "$rcvar_value" ]; then
case "$rcvar_value" in
[Yy][Ee][Ss] | [Tt][Rr][Uu][Ee] | [Oo][Nn] | 1) ;;
*)
echo "Error: beszel_agent_enable resolves to \"${rcvar_value}\" even though it was just set to YES in /etc/rc.conf."
echo "Another rc configuration file is overriding it. The most likely cause is a leftover:"
echo " /etc/rc.conf.d/beszel_agent"
echo "Remove or correct that file, then re-run this installer."
exit 1
;;
esac
fi
service beszel-agent restart service beszel-agent restart
# Check if service started successfully # Check if service started successfully