Compare commits

...

76 Commits

Author SHA1 Message Date
henrygd
4395520a28 Probable fix for Jetson gpu issue (#895) 2025-06-26 22:11:48 -04:00
Alexander Mnich
8c52f30a71 add GITHUB_TOKEN fallback for goreleaser (#925)
adding the fallback to the GITHUB_TOKEN allows execution of goreleaser in a fork without additional configuration
2025-06-26 21:03:19 -04:00
SSU
46316ebffa fix(install): suppress scoop output to avoid nssm path pollution (#918)
Suppressed the output of “scoop install beszel-agent” to ensure the NSSM service path
contains only the executable location.

Closes #915

Co-authored-by: suseol <suseol@geosr.com>
2025-06-25 13:52:45 -04:00
henrygd
0b04f60b6c Add panic recovery for sensors.TemperaturesWithContext (#796) 2025-06-23 19:50:11 -04:00
HansAndreManfredson
20b822d072 Fix missing groups #892 (#893) 2025-06-17 16:08:32 -04:00
Tobias Gruetzmacher
ca7642cc91 Create service user as system user (#867) 2025-06-12 14:54:36 -04:00
henrygd
68009c85a5 Add ppc64le agent build (#682) 2025-05-28 18:47:47 -04:00
Leon Blakey
1c7c64c4aa Add user to docker group in Debian package (#847) 2025-05-28 14:49:19 -04:00
henrygd
b05966d30b add help section to readme 2025-05-26 15:49:24 -04:00
Nikolas Garofil
ea90f6a596 Update readme.md 2025-05-26 14:45:23 -04:00
henrygd
f1e43b2593 scale fractional temperature values to reasonable Celsius values (#688) 2025-05-26 01:08:03 -04:00
henrygd
748d18321d fix: windows paths when regular and admin install users differ (#739) 2025-05-26 00:51:44 -04:00
henrygd
ae84919c39 update windows install command to use bypass execution policy (#739) 2025-05-24 21:49:43 -04:00
henrygd
b23221702e Handle systemd nvidia rules automatically during agent installation 2025-05-21 17:24:54 -04:00
henrygd
4d5b096230 Improve 'add system' dropdown buttons 2025-05-09 22:31:47 -04:00
henrygd
7caf7d1b31 Clear system's active alerts when system is paused 2025-05-08 20:41:44 -04:00
henrygd
6107f52d07 Fix system path in notification urls 2025-05-08 19:06:19 -04:00
henrygd
f4fb7a89e5 Add tests for GetSSHKey and handle read errors on key file 2025-05-08 18:54:14 -04:00
henrygd
5439066f4d hub.MakeLink method to assure URLs are formatted properly (#805)
- Updated AlertManager to replace direct app references with a hub interface.
- Changed AlertManager.app to AlertManager.hub
- Add tests for MakeLink
2025-05-08 17:47:15 -04:00
henrygd
7c18f3d8b4 Add mipsle agent build (#802) 2025-05-07 20:11:48 -04:00
henrygd
63af81666b Refactor SSH configuration and key management
- Restrict to specific key exchanges / MACs / ciphers.
- Refactored GetSSHKey method to return an ssh.Signer instead of byte array.
- Added common package.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-05-07 20:03:21 -04:00
henrygd
c0a6153a43 Update goreleaser configuration for beszel-agent to include restart delay and process type 2025-05-05 20:22:15 -04:00
henrygd
df334caca6 update install-agent.ps1 to support installing as admin (#797) 2025-05-05 20:21:37 -04:00
henrygd
ffb3ec0477 Fix broken link to notifications when using base path 2025-05-02 20:02:23 -04:00
henrygd
3a97edd0d5 add winget support to windows install script 2025-05-01 17:40:20 -04:00
henrygd
ab1d1c1273 Remove PrivateTmp setting from Systemd rules in install-agent.sh
Allows sharing socket in /tmp
2025-05-01 17:00:08 -04:00
henrygd
0fb39edae4 rename ssh imports in server.go 2025-04-30 18:09:25 -04:00
henrygd
3a977a8e1f goreleaser: update deprecated format field 2025-04-30 16:15:37 -04:00
henrygd
081979de24 Add winget configuration for beszel-agent 2025-04-30 15:00:26 -04:00
henrygd
23fe189797 Add SELinux context management to install-agent.sh (#788)
- Introduced functions to set and clean up SELinux contexts.
- Added SELinux context checks during the update process (systemd only).
- Updated the service execution command to use a dedicated update script.
2025-04-29 18:59:36 -04:00
henrygd
e9d429b9b8 Enhance service start check in install-agent.ps1
- Added logic to handle service start failures and check status with retries.
- Improved user feedback for service status during startup process.
2025-04-28 21:47:02 -04:00
henrygd
99202c85b6 re-enable docker image creation workflow 2025-04-28 21:16:32 -04:00
henrygd
d5c3d8f84e release 0.11.1 with new line so goreleaser doesn't fail :)
- Temp disable docker workflow bc image was already build
2025-04-28 20:57:27 -04:00
Alexander Mnich
8f442992e6 New German translations 2025-04-28 20:48:38 -04:00
henrygd
39820c8ac1 release 0.11.1 2025-04-28 20:38:24 -04:00
henrygd
0c8b10af99 Escape backslashes in windows agent install command (#785) 2025-04-28 20:37:25 -04:00
henrygd
8e072492b7 Skip checking Docker if DOCKER_HOST is set to an empty string 2025-04-28 20:23:54 -04:00
henrygd
88d6307ce0 Add FreeBSD icon 2025-04-28 19:37:00 -04:00
henrygd
2cc516f9e5 update readme and notifications link 2025-04-27 20:05:07 -04:00
henrygd
ab6ea71695 Release v0.11.0 2025-04-27 19:20:17 -04:00
henrygd
6280323cb1 Always display two decimals in container memory tooltip
- Also removes --china-mirrors when copying brew install script
2025-04-27 19:18:44 -04:00
henrygd
17c8e7e1bd Update bark notification to use url param for link 2025-04-27 14:07:33 -04:00
henrygd
f60fb6f8a9 Handle title and link for Lark notifications 2025-04-26 21:25:18 -04:00
henrygd
3eebbce2d4 Update Go dependencies
Also replaces containrrr/shoutrrr with pinned version of
nicholas-fedor/shoutrrr
2025-04-26 17:47:34 -04:00
henrygd
e92a94a24d update translations 2025-04-26 17:45:12 -04:00
Andypsl8
7c7c073ae4 New zh-CN translations 2025-04-26 14:58:06 -04:00
zoixc
c009a40749 New Russian translations 2025-04-26 14:56:23 -04:00
Alex van Steenhoven
5e85b803e0 New Dutch translations 2025-04-26 14:55:05 -04:00
henrygd
256d3c5ba1 Italian translations from @saamdotexe 2025-04-26 14:51:29 -04:00
henrygd
bd048a8989 French translations from leo.info54 on crowdin 2025-04-26 14:46:24 -04:00
henrygd
f6b4231500 new Arabic translations from rihla on crowdin 2025-04-26 14:41:31 -04:00
henrygd
bda06f30b3 Add temperature chart filtering (#430)
- Refactored ContainerFilterBar to accept a dynamic store prop.
- Updated filtering logic in ContainerChart to be case-insensitive.
2025-04-25 19:19:19 -04:00
henrygd
38f2ba3984 Small refactoring of docker manager
- Add isWindows flag to dockerManager
- `CalculateCpuPercentWindows` and `CalculateCpuPercentLinux` methods added to container.ApiStats
- Remove prevNetStats.Time in favor of Stats.PrevRead
- Replace regex Windows check with strings.Contains, and check the `/containers/json` response
2025-04-25 18:39:24 -04:00
ViryBe
1a7d897bdc compute cpu and memory stats for docker on windows (#653)
The Docker daemon's API returns different values on Windows and non-Windows systems. This impacts
the calculation of `cpuPct` and `usedMemory` for each container. The systems are disciminate on the
`Server` header send by the server. Uses the unix stats calculation in case the header is not set.

`docker stats` implementation for reference:
https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L100

Co-authored-by: Benoit VIRY <benoit.viry@cgx-group.com>
2025-04-25 18:27:36 -04:00
henrygd
c74e7430ef Disable h/l/left/right changing system if shift, ctrl, or meta keys are pressed (#703) 2025-04-23 16:40:58 -04:00
henrygd
2467bbc0f0 Add support for copying Windows and Homebrew installation commands 2025-04-23 14:17:14 -04:00
henrygd
ea665e02da Improve system information retrieval for macOS and Windows
- Introduce `Os` enum to represent supported operating systems.
- Update `SystemInfo` interface to include OS type.
- Refactor `ContainerChart` component to use `ChartType` enum for better clarity.
- Switched to dynamic units in container memory chart.
2025-04-22 20:29:17 -04:00
henrygd
358e05d544 truncate tooltip container name if very long 2025-04-22 20:16:11 -04:00
henrygd
aab5725d82 Use gpu temp as primary sensor if no other sensors 2025-04-18 18:00:39 -04:00
henrygd
e94a1cd421 brew install - change env var from PORT to LISTEN 2025-04-18 17:59:59 -04:00
henrygd
73c1a1b208 Refactor sensor configuration handling in tests and implementation
- Add skipCollection propery
- Ensure that sensors are initialized as an empty map
2025-04-18 17:59:25 -04:00
henrygd
0526c88ce0 support blacklisting and wildcard matching in SENSORS env var (#650)
- Moved sensor related code to sensors.go
- Added SensorConfig struct
- Added newSensorConfig
- Added tests
2025-04-17 21:08:05 -04:00
henrygd
a2e9056a00 update macos agent install script
- adds option to install homebrew if not installed
2025-04-15 17:54:53 -04:00
henrygd
fd4ac60908 Remove -Program parameter from windows firewall rule (#739) 2025-04-14 17:19:24 -04:00
henrygd
330e4c67f3 Update release workflow and goreleaser configuration
- Change tag pattern in release workflow to 'v*'
- Update description
2025-04-14 17:16:02 -04:00
henrygd
5d840bd473 Windows agent install script improvements
- Remove unneeded set-executionpolicy (in parent command)
- Wait before checking status
- Use direct binary path instead of shim
- Log to one file

Co-authored-by: vmhomelab <info@vmhomelab.de>
2025-04-13 19:56:23 -04:00
henrygd
54e3f3eba1 add goreleaser homebrew config and brew helper script 2025-04-09 19:58:46 -04:00
henrygd
d79111fce4 remove nvidia-smi dependency for jetson / tegrastats (#286) 2025-04-07 20:02:14 -04:00
henrygd
93c3c7b9d8 add windows agent install script 2025-04-06 22:09:43 -04:00
henrygd
410d236f89 fix EXTRA_FILESYSTEMS for windows (#422)
Co-authored-by: coosir <git@coosir.com>
2025-04-05 17:57:34 -04:00
henrygd
9a8071c314 prepend base path for command palette links 2025-04-05 17:34:33 -04:00
henrygd
80df0efccd add correct icon / label for windows build number 2025-04-05 17:33:41 -04:00
henrygd
3f1f4c7596 goreleaser: fix archive ids and add scoop for beszel-agent 2025-04-03 19:15:46 -04:00
Val V
04ac688be4 Agent OpenBSD release (#726) 2025-04-03 18:28:23 -04:00
henrygd
ace83172ff agent install script: improvements to --china-mirrors and --auto-update flags
- Allow using = to define flags
- Allow passing --auto-update=false to skip prompt
2025-03-18 15:58:41 -04:00
Daniel Hiller
e8b864b515 agent installer script: option to skip auto update question 2025-03-16 05:22:42 +01:00
71 changed files with 10208 additions and 7810 deletions

View File

@@ -3,7 +3,7 @@ name: Make release and binaries
on:
push:
tags:
- '*'
- 'v*'
permissions:
contents: write
@@ -39,4 +39,4 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -29,6 +29,7 @@ builds:
- linux
- darwin
- freebsd
- openbsd
- windows
goarch:
- amd64
@@ -36,9 +37,13 @@ builds:
- arm
- mips64
- riscv64
- mipsle
- ppc64le
ignore:
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: arm
- goos: windows
goarch: arm
- goos: darwin
@@ -47,8 +52,8 @@ builds:
goarch: riscv64
archives:
- id: beszel
format: tar.gz
- id: beszel-agent
formats: [tar.gz]
builds:
- beszel-agent
name_template: >-
@@ -57,10 +62,10 @@ archives:
{{- .Arch }}
format_overrides:
- goos: windows
format: zip
formats: [zip]
- id: beszel-agent
format: tar.gz
- id: beszel
formats: [tar.gz]
builds:
- beszel
name_template: >-
@@ -84,9 +89,6 @@ nfpms:
- beszel-agent
formats:
- deb
# don't think this is needed with CGO_ENABLED=0
# dependencies:
# - libc6
contents:
- src: ../supplemental/debian/beszel-agent.service
dst: lib/systemd/system/beszel-agent.service
@@ -111,6 +113,103 @@ nfpms:
# https://github.com/goreleaser/goreleaser/issues/5487
#config: ../supplemental/debian/config.sh
scoops:
- ids: [beszel-agent]
name: beszel-agent
repository:
owner: henrygd
name: beszel-scoops
homepage: 'https://beszel.dev'
description: 'Agent for Beszel, a lightweight server monitoring platform.'
license: MIT
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
# chocolateys:
# - title: Beszel Agent
# ids: [beszel-agent]
# package_source_url: https://github.com/henrygd/beszel-chocolatey
# owners: henrygd
# authors: henrygd
# summary: 'Agent for Beszel, a lightweight server monitoring platform.'
# description: |
# Beszel is a lightweight server monitoring platform that includes Docker statistics, historical data, and alert functions.
# It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
# license_url: https://github.com/henrygd/beszel/blob/main/LICENSE
# project_url: https://beszel.dev
# project_source_url: https://github.com/henrygd/beszel
# docs_url: https://beszel.dev/guide/getting-started
# icon_url: https://cdn.jsdelivr.net/gh/selfhst/icons/png/beszel.png
# bug_tracker_url: https://github.com/henrygd/beszel/issues
# copyright: 2025 henrygd
# tags: foss cross-platform admin monitoring
# require_license_acceptance: false
# release_notes: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
brews:
- ids: [beszel-agent]
name: beszel-agent
repository:
owner: henrygd
name: homebrew-beszel
homepage: 'https://beszel.dev'
description: 'Agent for Beszel, a lightweight server monitoring platform.'
license: MIT
extra_install: |
(bin/"beszel-agent-launcher").write <<~EOS
#!/bin/bash
set -a
if [ -f "$HOME/.config/beszel/beszel-agent.env" ]; then
source "$HOME/.config/beszel/beszel-agent.env"
fi
set +a
exec #{bin}/beszel-agent "$@"
EOS
(bin/"beszel-agent-launcher").chmod 0755
service: |
run ["#{bin}/beszel-agent-launcher"]
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
keep_alive true
restart_delay 5
name beszel-agent
process_type :background
winget:
- ids: [beszel-agent]
name: beszel-agent
package_identifier: henrygd.beszel-agent
publisher: henrygd
license: MIT
license_url: 'https://github.com/henrygd/beszel/blob/main/LICENSE'
copyright: '2025 henrygd'
homepage: 'https://beszel.dev'
release_notes_url: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
publisher_support_url: 'https://github.com/henrygd/beszel/issues'
short_description: 'Agent for Beszel, a lightweight server monitoring platform.'
skip_upload: auto
description: |
Beszel is a lightweight server monitoring platform that includes Docker
statistics, historical data, and alert functions. It has a friendly web
interface, simple configuration, and is ready to use out of the box.
It supports automatic backup, multi-user, OAuth authentication, and
API access.
tags:
- homelab
- monitoring
- self-hosted
repository:
owner: henrygd
name: beszel-winget
branch: henrygd.beszel-agent-{{ .Version }}
pull_request:
enabled: false
draft: false
base:
owner: microsoft
name: winget-pkgs
branch: master
release:
draft: true

View File

@@ -1,65 +1,48 @@
module beszel
go 1.24.0
go 1.24.2
// lock shoutrrr to specific version to allow review before updating
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
require (
github.com/blang/semver v3.5.1+incompatible
github.com/containrrr/shoutrrr v0.8.0
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5
github.com/nicholas-fedor/shoutrrr v0.8.8
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.25.9
github.com/pocketbase/pocketbase v0.27.1
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.2
github.com/shirou/gopsutil/v4 v4.25.3
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7
golang.org/x/crypto v0.37.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ganigeorgiev/fexpr v0.5.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/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -68,25 +51,18 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.40.0 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.223.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
modernc.org/libc v1.61.13 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
modernc.org/libc v1.64.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/sqlite v1.35.0 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
)

View File

@@ -1,75 +1,14 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.8 h1:RpwAfYcV2lr/yRc4lWhUM9JRPQqKgKWmou3LV7UfWP4=
github.com/aws/aws-sdk-go-v2/config v1.29.8/go.mod h1:t+G7Fq1OcO8cXTPPXzxQSnj/5Xzdc9jAAD3Xrn9/Mgo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 h1:Hd/uX6Wo2iUW1JWII+rmyCD7MMhOe7ALwQXN6sKDd1o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61/go.mod h1:L7vaLkwHY1qgW0gG1zG0z/X0sQ5tpIY5iI13+j3qI80=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 h1:RTko0AQ0i1vWXDM97DkuW6zskgOxFxm4RqC0kmBJFkE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64/go.mod h1:ty968MpOa5CoQ/ALWNB8Gmfoehof2nRHDR/DZDPfimE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 h1:EBm8lXevBWe+kK9VOU/IBeOI189WPRwPUc3LvJK9GOs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 h1:2U9sF8nKy7UgyEeLiZTRg6ShBS22z8UnYpV6aRFL0is=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 h1:wjAdc85cXdQR5uLx5FwWvGIHm4OPJhTyzUHU8craXtE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcPhKvYP5s1xf8/izn0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
@@ -80,65 +19,37 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/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.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
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-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
@@ -146,28 +57,17 @@ github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQF
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -175,31 +75,32 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb h1:YU0XAr3+rMpM8fP80KEesn32Qa9qkbquokvuwzWyYuA=
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/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.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.25.9 h1:/PSJcy39vEGv4lsBG4HV0ZFLcFsTdK9oMkJbxVlVJSs=
github.com/pocketbase/pocketbase v0.25.9/go.mod h1:gOnPr+g/GS+iqKh5XYXycdRWVGhiHY4c1H4TGjU9DDw=
github.com/pocketbase/pocketbase v0.27.1 h1:KGCsS8idUVTC5QHxTj91qHDhIXOb5Yb50wwHhNvJRTQ=
github.com/pocketbase/pocketbase v0.27.1/go.mod h1:aTpwwloVJzeJ7MlwTRrbI/x62QNR2/kkCrovmyrXpqs=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
@@ -207,8 +108,8 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -216,146 +117,73 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc=
google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e h1:YA5lmSs3zc/5w+xsRcHqpETkaYyK63ivEPzNTcUUlSA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -363,31 +191,28 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,42 +4,36 @@ package agent
import (
"beszel"
"beszel/internal/entities/system"
"context"
"log/slog"
"os"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/common"
)
type Agent struct {
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor
primarySensor string // Value of PRIMARY_SENSOR env var
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
}
func NewAgent() *Agent {
agent := &Agent{
sensorsContext: context.Background(),
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
}
agent.memCalc, _ = GetEnv("MEM_CALC")
agent.primarySensor, _ = GetEnv("PRIMARY_SENSOR")
agent.sensorConfig = agent.newSensorConfig()
// Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
@@ -55,24 +49,6 @@ func NewAgent() *Agent {
slog.Debug(beszel.Version)
// Set sensors context (allows overriding sys location for sensors)
if sysSensors, exists := GetEnv("SYS_SENSORS"); exists {
slog.Info("SYS_SENSORS", "path", sysSensors)
agent.sensorsContext = context.WithValue(agent.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
// Set sensors whitelist
if sensors, exists := GetEnv("SENSORS"); exists {
agent.sensorsWhitelist = make(map[string]struct{})
for sensor := range strings.SplitSeq(sensors, ",") {
if sensor != "" {
agent.sensorsWhitelist[sensor] = struct{}{}
}
}
}
// initialize system info / docker manager
agent.initializeSystemInfo()
agent.initializeDiskInfo()
@@ -119,11 +95,13 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
}
slog.Debug("System stats", "data", cachedData)
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
cachedData.Containers = containerStats
slog.Debug("Docker stats", "data", cachedData.Containers)
} else {
slog.Debug("Docker stats", "err", err)
if a.dockerManager != nil {
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
cachedData.Containers = containerStats
slog.Debug("Docker stats", "data", cachedData.Containers)
} else {
slog.Debug("Docker stats", "err", err)
}
}
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)

View File

@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -36,7 +37,12 @@ func (a *Agent) initializeDiskInfo() {
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool) {
key := filepath.Base(device)
var key string
if runtime.GOOS == "windows" {
key = device
} else {
key = filepath.Base(device)
}
var ioMatch bool
if _, exists := a.fsStats[key]; !exists {
if root {

View File

@@ -26,6 +26,7 @@ type dockerManager struct {
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
isWindows bool // Whether the Docker Engine API is running on Windows
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -69,6 +70,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
return nil, err
}
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
containersLength := len(dm.apiContainerList)
// store valid ids to clean up old container ids from map
@@ -80,8 +83,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
for _, ctr := range dm.apiContainerList {
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -167,22 +169,27 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
return err
}
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
// calculate cpu and memory stats
var usedMemory uint64
var cpuPct float64
if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
} else {
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
}
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory = res.MemoryStats.Usage - memCache
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
}
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
memCache := res.MemoryStats.Stats.InactiveFile
if memCache == 0 {
memCache = res.MemoryStats.Stats.Cache
}
usedMemory := res.MemoryStats.Usage - memCache
// cpu
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
@@ -197,18 +204,18 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
var sent_delta, recv_delta float64
// prevent first run from sending all prev sent/recv bytes
if initialized {
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
secondsElapsed := time.Since(stats.PrevRead).Seconds()
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Time = time.Now()
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
stats.PrevRead = res.Read
return nil
}
@@ -225,6 +232,10 @@ func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
slog.Info("DOCKER_HOST", "host", dockerHost)
// return nil if set to empty string
if dockerHost == "" {
return nil
}
} else {
dockerHost = getDockerHost()
}

View File

@@ -125,14 +125,13 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
// jetson devices have only one gpu so we'll just initialize here
gpuData := &system.GPUData{Name: "GPU"}
gm.GpuDataMap["0"] = gpuData
return func(output []byte) bool {
gm.Lock()
defer gm.Unlock()
// we get gpu name from the intitial run of nvidia-smi, so return if it hasn't been initialized
gpuData, ok := gm.GpuDataMap["0"]
if !ok {
return true
}
// Parse RAM usage
ramMatches := ramPattern.FindSubmatch(output)
if ramMatches != nil {
@@ -184,12 +183,6 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
if _, ok := gm.GpuDataMap[id]; !ok {
name := strings.TrimPrefix(fields[1], "NVIDIA ")
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
// check if tegrastats is active - if so we will only use nvidia-smi to get gpu name
// - nvidia-smi does not provide metrics for tegra / jetson devices
// this will end the nvidia-smi collector
if gm.tegrastats {
return false
}
}
// update gpu data
gpu := gm.GpuDataMap[id]
@@ -250,21 +243,26 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// sum the data
gpu.Temperature = twoDecimals(gpu.Temperature)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
// reset the count
gpu.Count = 1
// dereference to avoid overwriting anything else
gpuCopy := *gpu
var gpuAvg system.GPUData
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
// avoid division by zero
if gpu.Count > 0 {
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
}
// reset accumulators in the original
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
// append id to the name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuCopy
gpuData[id] = gpuAvg
}
slog.Debug("GPU", "data", gpuData)
return gpuData
@@ -283,6 +281,7 @@ func (gm *GPUManager) detectGPUs() error {
}
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true
gm.nvidiaSmi = false
}
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
return nil
@@ -297,9 +296,11 @@ func (gm *GPUManager) startCollector(command string) {
}
switch command {
case nvidiaSmiCmd:
collector.cmdArgs = []string{"-l", nvidiaSmiInterval,
collector.cmdArgs = []string{
"-l", nvidiaSmiInterval,
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
"--format=csv,noheader,nounits"}
"--format=csv,noheader,nounits",
}
collector.parse = gm.parseNvidiaData
go collector.start()
case tegraStatsCmd:

View File

@@ -251,14 +251,13 @@ func TestParseJetsonData(t *testing.T) {
tests := []struct {
name string
input string
gm *GPUManager
wantMetrics *system.GPUData
}{
{
name: "valid data",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{
Name: "Jetson",
Name: "GPU",
MemoryUsed: 4300.0,
MemoryTotal: 30698.0,
Usage: 45.0,
@@ -271,7 +270,7 @@ func TestParseJetsonData(t *testing.T) {
name: "more valid data",
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
wantMetrics: &system.GPUData{
Name: "Jetson",
Name: "GPU",
MemoryUsed: 6185.0,
MemoryTotal: 7620.0,
Usage: 63.0,
@@ -280,11 +279,24 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "orin nano",
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 3452.0,
MemoryTotal: 7620.0,
Usage: 0.0,
Temperature: 50.25,
Power: 0.518,
Count: 1,
},
},
{
name: "missing temperature",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{
Name: "Jetson",
Name: "GPU",
MemoryUsed: 4300.0,
MemoryTotal: 30698.0,
Usage: 45.0,
@@ -292,32 +304,18 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "no gpu defined by nvidia-smi",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
gm: &GPUManager{
GpuDataMap: map[string]*system.GPUData{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.gm != nil {
// should return if no gpu set by nvidia-smi
assert.Empty(t, tt.gm.GpuDataMap)
return
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
}
tt.gm = &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {Name: "Jetson"},
},
}
parser := tt.gm.getJetsonParser()
parser := gm.getJetsonParser()
valid := parser([]byte(tt.input))
assert.Equal(t, true, valid)
got := tt.gm.GpuDataMap["0"]
got := gm.GpuDataMap["0"]
require.NotNil(t, got)
assert.Equal(t, tt.wantMetrics.Name, got.Name)
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
@@ -333,44 +331,75 @@ func TestParseJetsonData(t *testing.T) {
}
func TestGetCurrentData(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
},
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
}
result := gm.GetCurrentData()
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
// Check averaged values in the result
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify that accumulators in the original map are reset
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
})
t.Run("handles zero count without panicking", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Count: 0,
Usage: 0,
Power: 0,
},
},
},
}
}
result := gm.GetCurrentData()
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
})
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Check averaged values
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify reset counts
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
// Verify reset count
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
})
}
func TestDetectGPUs(t *testing.T) {
@@ -443,7 +472,7 @@ echo "test"`
}
return nil
},
wantNvidiaSmi: true,
wantNvidiaSmi: false,
wantRocmSmi: true,
wantTegrastats: true,
wantErr: false,
@@ -737,6 +766,18 @@ func TestAccumulation(t *testing.T) {
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
}
// Verify that accumulators in the original map are reset
for id := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists {
continue
}
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
}
})
}
}

View File

@@ -0,0 +1,190 @@
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"log/slog"
"path"
"strconv"
"strings"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
)
type SensorConfig struct {
context context.Context
sensors map[string]struct{}
primarySensor string
isBlacklist bool
hasWildcards bool
skipCollection bool
}
func (a *Agent) newSensorConfig() *SensorConfig {
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
sysSensors, _ := GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
}
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
config := &SensorConfig{
context: context.Background(),
primarySensor: primarySensor,
skipCollection: skipCollection,
sensors: make(map[string]struct{}),
}
// Set sensors context (allows overriding sys location for sensors)
if sysSensors != "" {
slog.Info("SYS_SENSORS", "path", sysSensors)
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
// handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") {
config.isBlacklist = true
sensorsEnvVal = sensorsEnvVal[1:]
}
for sensor := range strings.SplitSeq(sensorsEnvVal, ",") {
sensor = strings.TrimSpace(sensor)
if sensor != "" {
config.sensors[sensor] = struct{}{}
if strings.Contains(sensor, "*") {
config.hasWildcards = true
}
}
}
return config
}
// updateTemperatures updates the agent with the latest sensor temperatures
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// skip if sensors whitelist is set to empty string
if a.sensorConfig.skipCollection {
slog.Debug("Skipping temperature collection")
return
}
// reset high temp
a.systemInfo.DashboardTemp = 0
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
// retry once on panic (gopsutil/issues/1832)
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 {
systemStats.Temperatures = make(map[string]float64)
}
return
}
}
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
if len(temps) == 0 {
return
}
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// scale temperature
if sensor.Temperature != 0 && sensor.Temperature < 1 {
sensor.Temperature = scaleTemperature(sensor.Temperature)
}
// skip if temperature is unreasonable
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
sensorName := sensor.SensorKey
if _, ok := systemStats.Temperatures[sensorName]; ok {
// if key already exists, append int to key
sensorName = sensorName + "_" + strconv.Itoa(i)
}
// skip if not in whitelist or blacklist
if !isValidSensor(sensorName, a.sensorConfig) {
continue
}
// set dashboard temperature
switch a.sensorConfig.primarySensor {
case "":
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
// get sensor data (error ignored intentionally as it may be only with one sensor)
temps, _ = getTemps(a.sensorConfig.context)
return
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid
if len(config.sensors) == 0 {
return true
}
// Exact match - return true if whitelist, false if blacklist
if _, exactMatch := config.sensors[sensorName]; exactMatch {
return !config.isBlacklist
}
// If no wildcards, return true if blacklist, false if whitelist
if !config.hasWildcards {
return config.isBlacklist
}
// Check for wildcard patterns
for pattern := range config.sensors {
if !strings.Contains(pattern, "*") {
continue
}
if match, _ := path.Match(pattern, sensorName); match {
return !config.isBlacklist
}
}
return config.isBlacklist
}
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
func scaleTemperature(temp float64) float64 {
if temp > 1 {
return temp
}
scaled100 := temp * 100
scaled1000 := temp * 1000
if scaled100 >= 15 && scaled100 <= 95 {
return scaled100
} else if scaled1000 >= 15 && scaled1000 <= 95 {
return scaled1000
}
return scaled100
}

View File

@@ -0,0 +1,553 @@
//go:build testing
// +build testing
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"os"
"testing"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsValidSensor(t *testing.T) {
tests := []struct {
name string
sensorName string
config *SensorConfig
expectedValid bool
}{
{
name: "Whitelist - sensor in list",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: false,
},
expectedValid: true,
},
{
name: "Whitelist - sensor not in list",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: false,
},
expectedValid: false,
},
{
name: "Blacklist - sensor in list",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: true,
},
expectedValid: false,
},
{
name: "Blacklist - sensor not in list",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}},
isBlacklist: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - matching pattern",
sensorName: "core_0_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Whitelist with wildcard - non-matching pattern",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - matching pattern",
sensorName: "core_0_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Blacklist with wildcard - non-matching pattern",
sensorName: "gpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "No sensors configured",
sensorName: "any_temp",
config: &SensorConfig{
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: false,
},
expectedValid: true,
},
{
name: "Mixed patterns in whitelist - exact match",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Mixed patterns in whitelist - wildcard match",
sensorName: "core_1_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: false,
hasWildcards: true,
},
expectedValid: true,
},
{
name: "Mixed patterns in blacklist - exact match",
sensorName: "cpu_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
{
name: "Mixed patterns in blacklist - wildcard match",
sensorName: "core_1_temp",
config: &SensorConfig{
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
isBlacklist: true,
hasWildcards: true,
},
expectedValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidSensor(tt.sensorName, tt.config)
assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName)
})
}
}
func TestNewSensorConfigWithEnv(t *testing.T) {
agent := &Agent{}
tests := []struct {
name string
primarySensor string
sysSensors string
sensors string
skipCollection bool
expectedConfig *SensorConfig
}{
{
name: "Empty configuration",
primarySensor: "",
sysSensors: "",
sensors: "",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: false,
},
},
{
name: "Explicitly set to empty string",
primarySensor: "",
sysSensors: "",
sensors: "",
skipCollection: true,
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: true,
},
},
{
name: "Primary sensor only - should create sensor map",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Whitelist sensors",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_temp,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: false,
},
},
{
name: "Blacklist sensors",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "-cpu_temp,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
},
isBlacklist: true,
hasWildcards: false,
},
},
{
name: "Sensors with wildcard",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_*,gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "Sensors with whitespace",
primarySensor: "cpu_temp",
sysSensors: "",
sensors: "cpu_*, gpu_temp",
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
},
isBlacklist: false,
hasWildcards: true,
},
},
{
name: "With SYS_SENSORS path",
primarySensor: "cpu_temp",
sysSensors: "/custom/path",
sensors: "cpu_temp",
expectedConfig: &SensorConfig{
primarySensor: "cpu_temp",
sensors: map[string]struct{}{
"cpu_temp": {},
},
isBlacklist: false,
hasWildcards: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
// Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
// Check sensor map
if tt.expectedConfig.sensors == nil {
assert.Nil(t, result.sensors)
} else {
assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors))
for sensor := range tt.expectedConfig.sensors {
_, exists := result.sensors[sensor]
assert.True(t, exists, "Sensor %s should exist in the result", sensor)
}
}
// Check flags
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
// Check context
if tt.sysSensors != "" {
// Verify context contains correct values
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "Context should contain EnvMap")
sysPath, ok := envMap[common.HostSysEnvKey]
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
assert.Equal(t, tt.sysSensors, sysPath)
}
})
}
}
func TestNewSensorConfig(t *testing.T) {
// Save original environment variables
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
// Restore environment variables after the test
defer func() {
// Clean up test environment variables
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
os.Unsetenv("BESZEL_AGENT_SENSORS")
// Restore original values if they existed
if hasPrimary {
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
}
if hasSys {
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
}
if hasSensors {
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
}
}()
// Set test environment variables
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
agent := &Agent{}
result := agent.newSensorConfig()
// Verify results
assert.Equal(t, "test_primary", result.primarySensor)
assert.NotNil(t, result.sensors)
assert.Equal(t, 3, len(result.sensors))
assert.True(t, result.hasWildcards)
assert.False(t, result.isBlacklist)
// Check that sys sensors path is in context
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "Context should contain EnvMap")
sysPath, ok := envMap[common.HostSysEnvKey]
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
assert.Equal(t, "/test/path", sysPath)
}
func TestScaleTemperature(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
desc string
}{
// Normal temperatures (no scaling needed)
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
// Zero temperature
{"zero_temp", 0.0, 0.0, "Zero temperature"},
// Fractional values that should use 100x scaling
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
// Fractional values that should use 1000x scaling
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
// Edge cases - values outside reasonable range
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
// Boundary cases around the reasonable range (15-95°C)
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
// Values just outside reasonable range
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scaleTemperature(tt.input)
assert.InDelta(t, tt.expected, result, 0.001,
"scaleTemperature(%v) = %v, expected %v (%s)",
tt.input, result, tt.expected, tt.desc)
})
}
}
func TestScaleTemperatureLogic(t *testing.T) {
// Test the logic flow for ambiguous cases
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
// 0.5 could be 50°C (100x) or 500°C (1000x)
// Should prefer 100x since it's tried first and is in range
result := scaleTemperature(0.5)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
result, expected)
})
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
// Should use 1000x since 100x is below reasonable range
result := scaleTemperature(0.05)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
result, expected)
})
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
// Should default to 100x scaling
result := scaleTemperature(0.005)
expected := 0.5
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
result, expected)
})
}
func TestGetTempsWithPanicRecovery(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
},
}
tests := []struct {
name string
getTempsFn getTempsFn
expectError bool
errorMsg string
}{
{
name: "successful_function_call",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, nil
},
expectError: false,
},
{
name: "function_returns_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, fmt.Errorf("sensor error")
},
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
},
{
name: "function_panics_with_string",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic("test panic")
},
expectError: true,
errorMsg: "panic: test panic",
},
{
name: "function_panics_with_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic(fmt.Errorf("panic error"))
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_index_out_of_bounds",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
slice := []int{1, 2, 3}
_ = slice[10] // out of bounds panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_any_conversion",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
var i any = "string"
_ = i.(int) // type assertion panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var temps []sensors.TemperatureStat
var err error
// The function should not panic, regardless of what the injected function does
assert.NotPanics(t, func() {
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
}, "getTempsWithPanicRecovery should not panic")
if tt.expectError {
assert.Error(t, err, "Expected an error to be returned")
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg,
"Error message should contain expected text")
}
assert.Nil(t, temps, "Temps should be nil when panic occurs")
} else {
assert.NoError(t, err, "Should not return error for successful calls")
}
})
}
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"beszel/internal/common"
"encoding/json"
"fmt"
"log/slog"
@@ -8,19 +9,17 @@ import (
"os"
"strings"
sshServer "github.com/gliderlabs/ssh"
"golang.org/x/crypto/ssh"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
type ServerOptions struct {
Addr string
Network string
Keys []ssh.PublicKey
Keys []gossh.PublicKey
}
func (a *Agent) StartServer(opts ServerOptions) error {
sshServer.Handle(a.handleSession)
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
if opts.Network == "unix" {
@@ -37,33 +36,57 @@ func (a *Agent) StartServer(opts ServerOptions) error {
}
defer ln.Close()
// Start SSH server on the listener
return sshServer.Serve(ln, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{}
config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
// set default handler
ssh.Handle(a.handleSession)
server := ssh.Server{
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
for _, pubKey := range opts.Keys {
if sshServer.KeysEqual(key, pubKey) {
if ssh.KeysEqual(key, pubKey) {
return true
}
}
return false
}),
)
},
// disable pty
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
// log failed connections
ConnectionFailedCallback: func(conn net.Conn, err error) {
slog.Warn("Failed connection attempt", "addr", conn.RemoteAddr().String(), "err", err)
},
}
// Start SSH server on the listener
return server.Serve(ln)
}
func (a *Agent) handleSession(s sshServer.Session) {
func (a *Agent) handleSession(s ssh.Session) {
slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
return
}
s.Exit(0)
}
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
func ParseKeys(input string) ([]ssh.PublicKey, error) {
var parsedKeys []ssh.PublicKey
func ParseKeys(input string) ([]gossh.PublicKey, error) {
var parsedKeys []gossh.PublicKey
for line := range strings.Lines(input) {
line = strings.TrimSpace(line)
// Skip empty lines or comments
@@ -71,7 +94,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
continue
}
// Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
}

View File

@@ -16,14 +16,31 @@ import (
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/sensors"
)
// Sets initial / non-changing values about the host system
func (a *Agent) initializeSystemInfo() {
a.systemInfo.AgentVersion = beszel.Version
a.systemInfo.Hostname, _ = os.Hostname()
a.systemInfo.KernelVersion, _ = host.KernelVersion()
platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" {
a.systemInfo.KernelVersion = version
a.systemInfo.Os = system.Darwin
} else if strings.Contains(platform, "indows") {
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
a.systemInfo.Os = system.Windows
} else if platform == "freebsd" {
a.systemInfo.Os = system.Freebsd
a.systemInfo.KernelVersion = version
} else {
a.systemInfo.Os = system.Linux
}
if a.systemInfo.KernelVersion == "" {
a.systemInfo.KernelVersion, _ = host.KernelVersion()
}
// cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 {
@@ -200,16 +217,24 @@ func (a *Agent) getSystemStats() system.Stats {
if systemStats.Temperatures == nil {
systemStats.Temperatures = make(map[string]float64, len(gpuData))
}
highestTemp := 0.0
for _, gpu := range gpuData {
if gpu.Temperature > 0 {
systemStats.Temperatures[gpu.Name] = gpu.Temperature
if a.primarySensor == gpu.Name {
if a.sensorConfig.primarySensor == gpu.Name {
a.systemInfo.DashboardTemp = gpu.Temperature
}
if gpu.Temperature > highestTemp {
highestTemp = gpu.Temperature
}
}
// update high gpu percent for dashboard
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
}
// use highest temp for dashboard temp if dashboard temp is unset
if a.systemInfo.DashboardTemp == 0 {
a.systemInfo.DashboardTemp = highestTemp
}
}
}
@@ -224,52 +249,6 @@ func (a *Agent) getSystemStats() system.Stats {
return systemStats
}
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// skip if sensors whitelist is set to empty string
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
slog.Debug("Skipping temperature collection")
return
}
// reset high temp
a.systemInfo.DashboardTemp = 0
// get sensor data
temps, _ := sensors.TemperaturesWithContext(a.sensorsContext)
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
if len(temps) == 0 {
return
}
systemStats.Temperatures = make(map[string]float64, len(temps))
for i, sensor := range temps {
// skip if temperature is unreasonable
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
continue
}
sensorName := sensor.SensorKey
if _, ok := systemStats.Temperatures[sensorName]; ok {
// if key already exists, append int to key
sensorName = sensorName + "_" + strconv.Itoa(i)
}
// skip if not in whitelist
if a.sensorsWhitelist != nil {
if _, nameInWhitelist := a.sensorsWhitelist[sensorName]; !nameInWhitelist {
continue
}
}
// set dashboard temperature
if a.primarySensor == "" {
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
} else if a.primarySensor == sensorName {
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// Returns the size of the ZFS ARC memory cache in bytes
func getARCSize() (uint64, error) {
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")

View File

@@ -8,15 +8,20 @@ import (
"sync"
"time"
"github.com/containrrr/shoutrrr"
"github.com/nicholas-fedor/shoutrrr"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/mailer"
)
type hubLike interface {
core.App
MakeLink(parts ...string) string
}
type AlertManager struct {
app core.App
hub hubLike
alertQueue chan alertTask
stopChan chan struct{}
pendingAlerts sync.Map
@@ -66,6 +71,7 @@ var supportsTitle = map[string]struct{}{
"gotify": {},
"ifttt": {},
"join": {},
"lark": {},
"matrix": {},
"ntfy": {},
"opsgenie": {},
@@ -78,9 +84,9 @@ var supportsTitle = map[string]struct{}{
}
// NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app core.App) *AlertManager {
func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{
app: app,
hub: app,
alertQueue: make(chan alertTask),
stopChan: make(chan struct{}),
}
@@ -90,7 +96,7 @@ func NewAlertManager(app core.App) *AlertManager {
func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings
record, err := am.app.FindFirstRecordByFilter(
record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}",
dbx.Params{"user": data.UserID},
)
@@ -103,12 +109,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
}
// send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
}
}
// send alerts via email
@@ -124,15 +130,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName,
Address: am.hub.Settings().Meta.SenderAddress,
Name: am.hub.Settings().Meta.SenderName,
},
}
err = am.app.NewMailClient().Send(&message)
err = am.hub.NewMailClient().Send(&message)
if err != nil {
return err
}
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
return nil
}
@@ -166,10 +172,12 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
// Add link
if scheme == "ntfy" {
// if ntfy, add link to actions
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
} else if scheme == "lark" {
queryParams.Add("link", link)
} else if scheme == "bark" {
queryParams.Add("url", link)
} else {
// else add link directly to the message
message += "\n\n" + link
}
@@ -180,9 +188,9 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
return err
}
return nil
@@ -198,7 +206,7 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
if url == "" {
return e.JSON(200, map[string]string{"err": "URL is required"})
}
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}

View File

@@ -2,7 +2,6 @@ package alerts
import (
"fmt"
"net/url"
"strings"
"time"
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
"system": systemID,
"name": "Status",
})
@@ -130,7 +129,7 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
}
// No alert scheduled for this record, send "up" alert
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
am.app.Logger().Error("Failed to send alert", "err", err.Error())
am.hub.Logger().Error("Failed to send alert", "err", err)
}
}
}
@@ -147,7 +146,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return errs["user"]
}
user := alertRecord.ExpandedOne("user")
@@ -159,7 +158,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
UserID: user.Id,
Title: title,
Message: message,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -3,7 +3,6 @@ package alerts
import (
"beszel/internal/entities/system"
"fmt"
"net/url"
"strings"
"time"
@@ -15,7 +14,7 @@ import (
)
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.app.FindAllRecords("alerts",
alertRecords, err := am.hub.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
@@ -101,7 +100,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
Created types.DateTime `db:"created"`
}{}
err = am.app.DB().
err = am.hub.DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
@@ -271,12 +270,12 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error())
if err := am.hub.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err)
return
}
// expand the user relation and send the alert
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
return
}
@@ -285,7 +284,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
UserID: user.Id,
Title: subject,
Message: body,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -0,0 +1,7 @@
package common
var (
DefaultKeyExchanges = []string{"curve25519-sha256"}
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
)

View File

@@ -27,38 +27,41 @@ type ApiInfo struct {
// Docker container resources from /containers/{id}/stats
type ApiStats struct {
// Common stats
// Read time.Time `json:"read"`
// PreRead time.Time `json:"preread"`
Read time.Time `json:"read"` // Time of stats generation
NumProcs uint32 `json:"num_procs,omitzero"` // Windows specific, not populated on Linux.
Networks map[string]NetworkStats
CPUStats CPUStats `json:"cpu_stats"`
MemoryStats MemoryStats `json:"memory_stats"`
}
// Linux specific stats, not populated on Windows.
// PidsStats PidsStats `json:"pids_stats,omitempty"`
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
return float64(cpuDelta) / float64(systemDelta) * 100
}
// Windows specific stats, not populated on Linux.
// NumProcs uint32 `json:"num_procs"`
// StorageStats StorageStats `json:"storage_stats,omitempty"`
// Networks request version >=1.21
Networks map[string]NetworkStats
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
// Max number of 100ns intervals between the previous time read and now
possIntervals := uint64(s.Read.Sub(prevRead).Nanoseconds())
possIntervals /= 100 // Convert to number of 100ns intervals
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
// Shared stats
CPUStats CPUStats `json:"cpu_stats,omitempty"`
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
// Intervals used
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
// Percentage avoiding divide-by-zero
if possIntervals > 0 {
return float64(intervalsUsed) / float64(possIntervals) * 100.0
}
return 0.00
}
type CPUStats struct {
// CPU Usage. Linux and Windows.
CPUUsage CPUUsage `json:"cpu_usage"`
// System Usage. Linux only.
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
// Online CPUs. Linux only.
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
// Throttling Data. Linux only.
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
}
type CPUUsage struct {
@@ -66,42 +69,15 @@ type CPUUsage struct {
// Units: nanoseconds (Linux)
// Units: 100's of nanoseconds (Windows)
TotalUsage uint64 `json:"total_usage"`
// Total CPU time consumed per core (Linux). Not used on Windows.
// Units: nanoseconds.
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
// Time spent by tasks of the cgroup in kernel mode (Linux).
// Time spent by all container processes in kernel mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
// Time spent by tasks of the cgroup in user mode (Linux).
// Time spent by all container processes in user mode (Windows).
// Units: nanoseconds (Linux).
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
// UsageInUsermode uint64 `json:"usage_in_usermode"`
}
type MemoryStats struct {
// current res_counter usage for memory
Usage uint64 `json:"usage,omitempty"`
// all the stats exported via memory.stat.
Stats MemoryStatsStats `json:"stats,omitempty"`
// maximum usage ever recorded.
// MaxUsage uint64 `json:"max_usage,omitempty"`
// TODO(vishh): Export these as stronger types.
// number of times memory usage hits limits.
// Failcnt uint64 `json:"failcnt,omitempty"`
// Limit uint64 `json:"limit,omitempty"`
// // committed bytes
// Commit uint64 `json:"commitbytes,omitempty"`
// // peak committed bytes
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
// // private working set
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
Stats MemoryStatsStats `json:"stats"`
// private working set (Windows only)
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
}
type MemoryStatsStats struct {
@@ -119,7 +95,6 @@ type NetworkStats struct {
type prevNetStats struct {
Sent uint64
Recv uint64
Time time.Time
}
// Docker container stats
@@ -131,4 +106,5 @@ type Stats struct {
NetworkRecv float64 `json:"nr"`
PrevCpu [2]uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevRead time.Time `json:"-"`
}

View File

@@ -64,6 +64,15 @@ type NetIoStats struct {
Name string
}
type Os uint8
const (
Linux Os = iota
Darwin
Windows
Freebsd
)
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
@@ -79,6 +88,7 @@ type Info struct {
Podman bool `json:"p,omitempty"`
GpuPct float64 `json:"g,omitempty"`
DashboardTemp float64 `json:"dt,omitempty"`
Os Os `json:"os"`
}
// Final data structure to return to the hub

View File

@@ -10,11 +10,13 @@ import (
"beszel/site"
"crypto/ed25519"
"encoding/pem"
"fmt"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"strings"
"github.com/pocketbase/pocketbase"
@@ -56,7 +58,6 @@ func GetEnv(key string) (value string, exists bool) {
}
func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// initialize settings / collections
if err := h.initialize(e); err != nil {
@@ -156,7 +157,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
return nil
}
// startServer starts the server for the Beszel (not PocketBase)
// startServer sets up the server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
// TODO: exclude dev server from production binary
switch h.IsDev() {
@@ -239,73 +240,63 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return nil
}
// generates key pair if it doesn't exist and returns private key bytes
func (h *Hub) GetSSHKey() ([]byte, error) {
dataDir := h.DataDir()
// generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
privateKeyPath := path.Join(dataDir, "id_ed25519")
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
existingKey, err := os.ReadFile(privateKeyPath)
if err == nil {
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
private, err := ssh.ParsePrivateKey(existingKey)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %s", err)
}
// return existing private key
return existingKey, nil
pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
return private, nil
} else if !os.IsNotExist(err) {
// File exists but couldn't be read for some other reason
return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err)
}
// Generate the Ed25519 key pair
pubKey, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
// h.Logger().Error("Error generating key pair:", "err", err.Error())
return nil, err
}
// Get the private key in OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
return nil, err
}
// Save the private key to a file
privateFile, err := os.Create(dataDir + "/id_ed25519")
if err != nil {
// h.Logger().Error("Error creating private key file:", "err", err.Error())
return nil, err
}
defer privateFile.Close()
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
return nil, err
}
// Generate the public key in OpenSSH format
publicKey, err := ssh.NewPublicKey(pubKey)
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return nil, err
}
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
}
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
sshPubKey, _ := ssh.NewPublicKey(pubKey)
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
// Save the public key to a file
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
if err != nil {
return nil, err
}
defer publicFile.Close()
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.Logger().Info("ed25519 SSH key pair generated successfully.")
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
h.Logger().Info("Saved to: " + privateKeyPath)
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
return existingKey, nil
}
return nil, err
return sshPrivate, err
}
// MakeLink formats a link with the app URL and path segments.
// Only path segments should be provided.
func (h *Hub) MakeLink(parts ...string) string {
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
for _, part := range parts {
if part == "" {
continue
}
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
}
return base
}

View File

@@ -0,0 +1,257 @@
//go:build testing
// +build testing
package hub
import (
"testing"
"crypto/ed25519"
"encoding/pem"
"os"
"path/filepath"
"strings"
"github.com/pocketbase/pocketbase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func getTestHub() *Hub {
app := pocketbase.New()
return NewHub(app)
}
func TestMakeLink(t *testing.T) {
hub := getTestHub()
tests := []struct {
name string
appURL string
parts []string
expected string
}{
{
name: "no parts, no trailing slash in AppURL",
appURL: "http://localhost:8090",
parts: []string{},
expected: "http://localhost:8090",
},
{
name: "no parts, with trailing slash in AppURL",
appURL: "http://localhost:8090/",
parts: []string{},
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
},
{
name: "one part",
appURL: "http://example.com",
parts: []string{"one"},
expected: "http://example.com/one",
},
{
name: "multiple parts",
appURL: "http://example.com",
parts: []string{"alpha", "beta", "gamma"},
expected: "http://example.com/alpha/beta/gamma",
},
{
name: "parts with spaces needing escaping",
appURL: "http://example.com",
parts: []string{"path with spaces", "another part"},
expected: "http://example.com/path%20with%20spaces/another%20part",
},
{
name: "parts with slashes needing escaping",
appURL: "http://example.com",
parts: []string{"a/b", "c"},
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
},
{
name: "AppURL with subpath, no trailing slash",
appURL: "http://localhost/sub",
parts: []string{"resource"},
expected: "http://localhost/sub/resource",
},
{
name: "AppURL with subpath, with trailing slash",
appURL: "http://localhost/sub/",
parts: []string{"item"},
expected: "http://localhost/sub/item",
},
{
name: "empty parts in the middle",
appURL: "http://localhost",
parts: []string{"first", "", "third"},
expected: "http://localhost/first/third",
},
{
name: "leading and trailing empty parts",
appURL: "http://localhost",
parts: []string{"", "path", ""},
expected: "http://localhost/path",
},
{
name: "parts with various special characters",
appURL: "https://test.dev/",
parts: []string{"p@th?", "key=value&"},
expected: "https://test.dev/p@th%3F/key=value&",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Store original app URL and restore it after the test
originalAppURL := hub.Settings().Meta.AppURL
hub.Settings().Meta.AppURL = tt.appURL
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
got := hub.MakeLink(tt.parts...)
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
})
}
}
func TestGetSSHKey(t *testing.T) {
hub := getTestHub()
// Test Case 1: Key generation (no existing key)
t.Run("KeyGeneration", func(t *testing.T) {
tempDir := t.TempDir()
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
hub.pubKey = ""
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer")
// Check if private key file was created
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
info, err := os.Stat(privateKeyPath)
assert.NoError(t, err, "Private key file should be created")
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
// Check if h.pubKey was set
assert.NotEmpty(t, hub.pubKey, "h.pubKey should be set after key generation")
assert.True(t, strings.HasPrefix(hub.pubKey, "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
// Verify the generated private key is parsable
keyData, err := os.ReadFile(privateKeyPath)
require.NoError(t, err)
_, err = ssh.ParsePrivateKey(keyData)
assert.NoError(t, err, "Generated private key should be parsable by ssh.ParsePrivateKey")
})
// Test Case 2: Existing key
t.Run("ExistingKey", func(t *testing.T) {
tempDir := t.TempDir()
// Manually create a valid key pair for the test
rawPubKey, rawPrivKey, err := ed25519.GenerateKey(nil)
require.NoError(t, err, "Failed to generate raw ed25519 key pair for pre-existing key test")
// Marshal the private key into OpenSSH PEM format
pemBlock, err := ssh.MarshalPrivateKey(rawPrivKey, "")
require.NoError(t, err, "Failed to marshal private key to PEM block for pre-existing key test")
privateKeyBytes := pem.EncodeToMemory(pemBlock)
require.NotNil(t, privateKeyBytes, "PEM encoded private key bytes should not be nil")
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
err = os.WriteFile(privateKeyPath, privateKeyBytes, 0600)
require.NoError(t, err, "Failed to write pre-existing private key")
// Determine the expected public key string
sshPubKey, err := ssh.NewPublicKey(rawPubKey)
require.NoError(t, err)
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
hub.pubKey = ""
signer, err := hub.GetSSHKey(tempDir)
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
// Check if h.pubKey was set correctly to the public key from the file
assert.Equal(t, expectedPubKeyStr, hub.pubKey, "h.pubKey should match the existing public key")
// Verify the signer's public key matches the original public key
signerPubKey := signer.PublicKey()
marshaledSignerPubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signerPubKey)))
assert.Equal(t, expectedPubKeyStr, marshaledSignerPubKey, "Signer's public key should match the existing public key")
})
// Test Case 3: Error cases
t.Run("ErrorCases", func(t *testing.T) {
tests := []struct {
name string
setupFunc func(dir string) error
errorCheck func(t *testing.T, err error)
}{
{
name: "CorruptedKey",
setupFunc: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte("this is not a valid SSH key"), 0600)
},
errorCheck: func(t *testing.T, err error) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "ssh: no key found")
},
},
{
name: "PermissionDenied",
setupFunc: func(dir string) error {
// Create the key file
keyPath := filepath.Join(dir, "id_ed25519")
if err := os.WriteFile(keyPath, []byte("dummy content"), 0600); err != nil {
return err
}
// Make it read-only (can't be opened for writing in case a new key needs to be written)
return os.Chmod(keyPath, 0400)
},
errorCheck: func(t *testing.T, err error) {
// On read-only key, the parser will attempt to parse it and fail with "ssh: no key found"
assert.Error(t, err)
},
},
{
name: "EmptyFile",
setupFunc: func(dir string) error {
// Create an empty file
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte{}, 0600)
},
errorCheck: func(t *testing.T, err error) {
assert.Error(t, err)
// The error from attempting to parse an empty file
assert.Contains(t, err.Error(), "ssh: no key found")
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tempDir := t.TempDir()
// Setup the test case
err := tc.setupFunc(tempDir)
require.NoError(t, err, "Setup failed")
// Reset h.pubKey before each test case
hub.pubKey = ""
// Attempt to get SSH key
_, err = hub.GetSSHKey(tempDir)
// Verify the error
tc.errorCheck(t, err)
// Check that pubKey was not set in error cases
assert.Empty(t, hub.pubKey, "h.pubKey should not be set if there was an error")
})
}
})
}

View File

@@ -1,6 +1,7 @@
package systems
import (
"beszel/internal/common"
"beszel/internal/entities/system"
"context"
"fmt"
@@ -45,7 +46,7 @@ type System struct {
type hubLike interface {
core.App
GetSSHKey() ([]byte, error)
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
@@ -62,13 +63,10 @@ func NewSystemManager(hub hubLike) *SystemManager {
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// ssh setup
key, err := sm.hub.GetSSHKey()
err := sm.createSSHClientConfig()
if err != nil {
return err
}
if err := sm.createSSHClientConfig(key); err != nil {
return err
}
// start updating existing systems
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
@@ -124,7 +122,8 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
switch newStatus {
case paused:
sm.RemoveSystem(e.Record.Id)
_ = sm.RemoveSystem(e.Record.Id)
_ = deactivateAlerts(e.App, e.Record.Id)
return e.Next()
case pending:
if err := sm.AddRecord(e.Record); err != nil {
@@ -362,15 +361,21 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
return nil, fmt.Errorf("failed to fetch data")
}
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
signer, err := ssh.ParsePrivateKey(key)
// createSSHClientConfig initializes the ssh config for the system manager
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey(sm.hub.DataDir())
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
ssh.PublicKeys(privateKey),
},
Config: ssh.Config{
Ciphers: common.DefaultCiphers,
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout,
@@ -433,3 +438,20 @@ func (sys *System) resetSSHClient() {
}
sys.client = nil
}
// deactivateAlerts finds all triggered alerts for a system and sets them to false
func deactivateAlerts(app core.App, systemID string) error {
// we can't use an UPDATE query because it doesn't work with realtime updates
// _, err := e.App.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", e.Record.Id)).Execute()
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
if err != nil {
return err
}
for _, alert := range alerts {
alert.Set("triggered", false)
if err := app.SaveNoValidate(alert); err != nil {
return err
}
}
return nil
}

View File

@@ -33,6 +33,9 @@ export default defineConfig({
],
sourceLocale: "en",
compileNamespace: "ts",
formatOptions: {
lineNumbers: false,
},
catalogs: [
{
path: "<rootDir>/src/locales/{locale}/{locale}",

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.10.2",
"version": "0.11.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -70,4 +70,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -19,11 +19,12 @@ import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import { i18n } from "@lingui/core"
import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import { basePath, navigate } from "./router"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { SystemRecord } from "@/types"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
@@ -72,15 +73,23 @@ function copyDockerRun(port = "45876", publicKey: string) {
)
}
function copyInstallCommand(port = "45876", publicKey: string) {
let cmd = `curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
// add china mirrors flag if zh-CN
if ((i18n.locale + navigator.language).includes("zh-CN")) {
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
let cmd = `curl -sL https://get.beszel.dev${
brew ? "/brew" : ""
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
// brew script does not support --china-mirrors
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
}
function copyWindowsCommand(port = "45876", publicKey: string) {
copyToClipboard(
`& iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
)
}
/**
* SystemDialog component for adding or editing a system.
* @param {Object} props - The component props.
@@ -197,7 +206,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
className="absolute end-0 top-0"
onClick={() => copyToClipboard(publicKey)}
>
<Copy className="h-4 w-4 " />
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -213,19 +222,41 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t`Copy` + " docker compose"}
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
dropdownText={t`Copy` + " docker run"}
dropdownOnClick={() => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey)}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<DockerIcon className="size-4" />],
},
]}
/>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" className="contents">
<CopyButton
text={t`Copy Linux command`}
onClick={() => copyInstallCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
dropdownText={t`Manual setup instructions`}
dropdownUrl="https://beszel.dev/guide/agent-installation#binary"
icon={<TuxIcon className="size-4" />}
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<WindowsIcon className="size-4" />],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [<ExternalLinkIcon className="size-4" />],
},
]}
/>
</TabsContent>
{/* Save */}
@@ -237,19 +268,30 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
)
})
interface DropdownItem {
text: string
onClick?: () => void
url?: string
icons?: React.ReactNode[]
}
interface CopyButtonProps {
text: string
onClick: () => void
dropdownText: string
dropdownOnClick?: () => void
dropdownUrl?: string
dropdownItems: DropdownItem[]
icon?: React.ReactNode
}
const CopyButton = memo((props: CopyButtonProps) => {
return (
<div className="flex gap-0 rounded-lg">
<Button type="button" variant="outline" onClick={props.onClick} className="rounded-e-none dark:border-e-0 grow">
{props.text}
<Button
type="button"
variant="outline"
onClick={props.onClick}
className="rounded-e-none dark:border-e-0 grow flex items-center gap-2"
>
{props.text} {props.icon}
</Button>
<div className="w-px h-full bg-muted"></div>
<DropdownMenu>
@@ -259,15 +301,20 @@ const CopyButton = memo((props: CopyButtonProps) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{props.dropdownUrl ? (
<DropdownMenuItem asChild>
<a href={props.dropdownUrl} className="cursor-pointer" target="_blank" rel="noopener noreferrer">
{props.dropdownText}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={props.dropdownOnClick} className="cursor-pointer">{props.dropdownText}</DropdownMenuItem>
)}
{props.dropdownItems.map((item, index) => {
const className = "cursor-pointer flex items-center gap-1.5"
return item.url ? (
<DropdownMenuItem key={index} asChild>
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
{item.text} {item.icons?.map((icon) => icon)}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
{item.text} {item.icons?.map((icon) => icon)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react"
import { $alerts } from "@/lib/stores"
@@ -15,10 +15,11 @@ import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types"
import { Link } from "../router"
import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
@@ -81,7 +82,7 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
<DialogDescription>
<Trans>
See{" "}
<Link href="/settings/notifications" className="link">
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.

View File

@@ -16,16 +16,17 @@ import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores"
import { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { ChartType } from "@/lib/enums"
export default memo(function ContainerChart({
dataKey,
chartData,
chartName,
chartType,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartName: string
chartType: ChartType
unit?: string
}) {
const filter = useStore($containerFilter)
@@ -33,7 +34,7 @@ export default memo(function ContainerChart({
const { containerData } = chartData
const isNetChart = chartName === "net"
const isNetChart = chartType === ChartType.Network
const chartConfig = useMemo(() => {
let config = {} as Record<
@@ -81,7 +82,7 @@ export default memo(function ContainerChart({
tickFormatter: (value: any) => string
}
// tick formatter
if (chartName === "cpu") {
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
return updateYAxisWidth(val)
@@ -111,6 +112,11 @@ export default memo(function ContainerChart({
return null
}
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { v, u } = getSizeAndUnit(item.value, false)
return decimalString(v, 2) + u
}
} else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
}
@@ -157,13 +163,14 @@ export default memo(function ContainerChart({
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-ignore
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filter && !key.includes(filter)
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
let fillOpacity = filtered ? 0.05 : 0.4
let strokeOpacity = filtered ? 0.1 : 1
return (

View File

@@ -18,8 +18,11 @@ import {
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
import { $temperatureFilter } from "@/lib/stores"
import { useStore } from "@nanostores/react"
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
const filter = useStore($temperatureFilter)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
@@ -86,22 +89,28 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " °C"}
// indicator="line"
filter={filter}
/>
}
/>
{colors.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
isAnimationActive={false}
/>
))}
{colors.map((key) => {
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
let strokeOpacity = filtered ? 0.1 : 1
return (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
isAnimationActive={false}
/>
)
})}
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>

View File

@@ -22,7 +22,7 @@ import {
import { memo, useEffect, useMemo } from "react"
import { $systems } from "@/lib/stores"
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
import { $router, basePath, navigate } from "./router"
import { $router, basePath, navigate, prependBasePath } from "./router"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { getPagePath } from "@nanostores/router"
@@ -133,7 +133,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
keywords={["pocketbase"]}
onSelect={() => {
setOpen(false)
window.open("/_/", "_blank")
window.open(prependBasePath("/_/"), "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
@@ -147,7 +147,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/logs", "_blank")
window.open(prependBasePath("/_/#/logs"), "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
@@ -161,7 +161,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/backups", "_blank")
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
@@ -176,7 +176,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
keywords={["email"]}
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/mail", "_blank")
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />

View File

@@ -46,11 +46,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Want to help us make our translations even better? Check out{" "}
Want to help improve our translations? Check{" "}
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
Crowdin
</a>{" "}
for more details.
for details.
</Trans>
</p>
</div>

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -128,7 +128,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link">
Shoutrrr
</a>{" "}
to integrate with popular notification services.

View File

@@ -1,7 +1,17 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores"
import {
$systems,
pb,
$chartTime,
$containerFilter,
$userSettings,
$direction,
$maxValues,
$temperatureFilter,
} from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import { ChartType, Os } from "@/lib/enums"
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react"
@@ -22,7 +32,7 @@ import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } from "../ui/icons"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { timeTicks } from "d3-time"
@@ -218,7 +228,7 @@ export default function SystemDetail({ name }: { name: string }) {
cache.set(cs_cache_key, containerData)
}
if (containerData.length) {
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
!containerFilterBar && setContainerFilterBar(<FilterBar />)
} else if (containerFilterBar) {
setContainerFilterBar(null)
}
@@ -251,6 +261,27 @@ export default function SystemDetail({ name }: { name: string }) {
if (!system.info) {
return []
}
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
value: system.info.k,
label: t({ comment: "Linux kernel", message: "Kernel" }),
},
[Os.Darwin]: {
Icon: AppleIcon,
value: `macOS ${system.info.k}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: system.info.k,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: system.info.k,
},
}
let uptime: React.ReactNode
if (system.info.u < 172800) {
const hours = Math.trunc(system.info.u / 3600)
@@ -268,7 +299,7 @@ export default function SystemDetail({ name }: { name: string }) {
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
osInfo[system.info.os ?? Os.Linux],
{
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
Icon: CpuIcon,
@@ -302,7 +333,13 @@ export default function SystemDetail({ name }: { name: string }) {
return
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.shiftKey ||
e.ctrlKey ||
e.metaKey
) {
return
}
const currentIndex = systems.findIndex((s) => s.name === name)
@@ -446,7 +483,7 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar}
>
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
<ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
</ChartCard>
)}
@@ -467,7 +504,7 @@ export default function SystemDetail({ name }: { name: string }) {
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
<ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
</ChartCard>
)}
@@ -509,7 +546,7 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={containerFilterBar}
>
{/* @ts-ignore */}
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
<ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
</ChartCard>
</div>
)}
@@ -533,6 +570,7 @@ export default function SystemDetail({ name }: { name: string }) {
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
>
<TemperatureChart chartData={chartData} />
</ChartCard>
@@ -630,12 +668,12 @@ export default function SystemDetail({ name }: { name: string }) {
)
}
function ContainerFilterBar() {
const containerFilter = useStore($containerFilter)
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const containerFilter = useStore(store)
const { t } = useLingui()
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value)
store.set(e.target.value)
}, [])
return (
@@ -648,7 +686,7 @@ function ContainerFilterBar() {
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={() => $containerFilter.set("")}
onClick={() => store.set("")}
>
<XIcon className="h-4 w-4" />
</Button>

View File

@@ -99,6 +99,7 @@ const ChartTooltipContent = React.forwardRef<
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean
}
>(
(
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
filter,
itemSorter,
contentFormatter: content = undefined,
truncate = false,
},
ref
) => {
@@ -127,7 +129,7 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => {
if (filter) {
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
}
if (itemSorter) {
// @ts-ignore
@@ -214,10 +216,15 @@ const ChartTooltipContent = React.forwardRef<
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{nestLabel ? tooltipLabel : null}
<span
className={cn(
"text-muted-foreground",
truncate ? "max-w-40 truncate leading-normal -my-1" : ""
)}
>
{itemConfig?.label || item.name}
</span>
{item.value !== undefined && (
<span className="font-medium tabular-nums text-foreground">
{content && typeof content === "function"

View File

@@ -12,6 +12,54 @@ export function TuxIcon(props: SVGProps<SVGSVGElement>) {
)
}
// icon park (Apache 2.0) https://github.com/bytedance/IconPark/blob/master/LICENSE
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 48 48">
<path
fill="none"
stroke="currentColor"
strokeWidth="3.8"
d="m6.8 11 12.9-1.7v12.1h-13zm18-2.2 16.4-2v14.6H25zm0 18.6 16.4.4v13.4L25 38.6zm-18-.8 12.9.3v10.9l-13-2.2z"
/>
</svg>
)
}
// teenyicons (MIT) https://github.com/teenyicons/teenyicons/blob/master/LICENSE
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 20 20" {...props}>
<path
fill="currentColor"
d="M14.1 4.7a5 5 0 0 1 3.8 2c-3.3 1.9-2.8 6.7.6 8L17.2 17c-.8 1.3-2 2.9-3.5 2.9-1.2 0-1.6-.9-3.3-.8s-2.2.8-3.5.8c-1.4 0-2.5-1.5-3.4-2.7-2.3-3.6-2.5-7.9-1.1-10 1-1.7 2.6-2.6 4.1-2.6 1.6 0 2.6.8 3.8.8 1.3 0 2-.8 3.8-.8M13.7 0c.2 1.2-.3 2.4-1 3.2a4 4 0 0 1-3 1.6c-.2-1.2.3-2.3 1-3.2.7-.8 2-1.5 3-1.6"
/>
</svg>
)
}
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
export function FreeBsdIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M2.7 2C3.5 2 6 3.2 6 3.2 4.8 4 3.7 5 3 6.4 2.1 4.8 1.3 2.9 2 2.2l.7-.2m18.1.1c.4 0 .8 0 1 .2 1 1.1-2 5.8-2.4 6.4-.5.5-1.8 0-2.9-1-1-1.2-1.5-2.4-1-3 .4-.4 3.6-2.4 5.3-2.6m-8.8.5c1.3 0 2.5.2 3.7.7l-1 .7c-1 1-.6 2.8 1 4.4 1 1 2.1 1.6 3 1.6a2 2 0 0 0 1.5-.6l.7-1a9.7 9.7 0 1 1-18.6 3.8A9.7 9.7 0 0 1 12 2.7"
/>
</svg>
)
}
// ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE
export function DockerIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 512 512" fill="currentColor">
<path d="M507 211c-1-1-14-11-42-11a133 133 0 0 0-21 2c-6-36-36-54-37-55l-7-4-5 7a102 102 0 0 0-13 30c-5 21-2 40 8 57-12 7-33 9-37 9H16a16 16 0 0 0-16 16 241 241 0 0 0 15 87c11 30 29 53 51 67 25 15 66 24 113 24a344 344 0 0 0 62-6 257 257 0 0 0 82-29 224 224 0 0 0 55-46c27-30 43-64 55-94h4c30 0 48-12 58-22a63 63 0 0 0 15-22l2-6Z" />
<path d="M47 236h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4H47a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m-125-57h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m0-58h45a4 4 0 0 0 4-4V76a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 116h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4" />
</svg>
)
}
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
export function Rows(props: SVGProps<SVGSVGElement>) {
return (

View File

@@ -0,0 +1,13 @@
export enum Os {
Linux = 0,
Darwin,
Windows,
FreeBSD,
}
export enum ChartType {
Memory,
Disk,
Network,
CPU,
}

View File

@@ -38,6 +38,9 @@ $userSettings.subscribe((value) => {
/** Container chart filter */
export const $containerFilter = atom("")
/** Temperature chart filter */
export const $temperatureFilter = atom("")
/** Fallback copy to clipboard dialog content */
export const $copyContent = atom("")

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { RecordModel } from "pocketbase"
import { Os } from "./lib/enums"
// global window properties
declare global {
@@ -48,6 +49,8 @@ export interface SystemInfo {
g?: number
/** dashboard display temperature */
dt?: number
/** operating system */
os?: Os
}
export interface SystemStats {

View File

@@ -1,6 +1,6 @@
package beszel
const (
Version = "0.10.2"
Version = "0.11.1"
AppName = "beszel"
)

View File

@@ -4,23 +4,23 @@ Beszel is a lightweight server monitoring platform that includes Docker statisti
It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
[![agent Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel-agent/0.4.0?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent)
[![hub Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel/0.4.0?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel)
[![agent Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel-agent/latest?logo=docker&label=agent%20image%20size)](https://hub.docker.com/r/henrygd/beszel-agent)
[![hub Docker Image Size](https://img.shields.io/docker/image-size/henrygd/beszel/latest?logo=docker&label=hub%20image%20size)](https://hub.docker.com/r/henrygd/beszel)
[![MIT license](https://img.shields.io/github/license/henrygd/beszel?color=%239944ee)](https://github.com/henrygd/beszel/blob/main/LICENSE)
[![Crowdin](https://badges.crowdin.net/beszel/localized.svg)](https://crowdin.com/project/beszel)
![Screenshot of beszel dashboard and system page](https://henrygd-assets.b-cdn.net/beszel/screenshot-new.png)
![Screenshot of Beszel dashboard and system page, side by side. The dashboard shows metrics from multiple connected systems, while the system page shows detailed metrics for a single system.](https://henrygd-assets.b-cdn.net/beszel/screenshot-new.png)
## Features
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
- **Simple**: Easy setup, no need for public internet exposure.
- **Simple**: Easy setup with little manual configuration required.
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
- **REST API**: Use or update your data in your own scripts and applications.
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
<!-- - **REST API**: Use or update your data in your own scripts and applications. -->
## Architecture
@@ -49,6 +49,18 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
- **Temperature** - Host system sensors.
- **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
## Help and discussion
Please search existing issues and discussions before opening a new one. I try my best to respond, but may not always have time to do so.
#### Bug reports and feature requests
Bug reports and detailed feature requests should be posted on [GitHub issues](https://github.com/henrygd/beszel/issues).
#### Support and general discussion
Support requests and general discussion can be posted on [GitHub discussions](https://github.com/henrygd/beszel/discussions) or the community-run [Matrix room](https://matrix.to/#/#beszel:matrix.org): `#beszel:matrix.org`.
## License
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.

View File

@@ -31,6 +31,12 @@ if ! getent passwd "$SERVICE_USER" >/dev/null; then
--gecos "System user for $SERVICE"
fi
# Enable docker
if ! getent group docker | grep -q "$SERVICE_USER"; then
echo "Adding $SERVICE_USER to docker group"
usermod -aG docker "$SERVICE_USER"
fi
# Create config file if it doesn't already exist
if [ ! -f "$CONFIG_FILE" ]; then
touch "$CONFIG_FILE"

View File

@@ -0,0 +1,83 @@
#!/bin/bash
PORT=45876
KEY=""
usage() {
printf "Beszel Agent homebrew installation script\n\n"
printf "Usage: ./install-agent-brew.sh [options]\n\n"
printf "Options: \n"
printf " -k SSH key (required, or interactive if not provided)\n"
printf " -p Port (default: $PORT)\n"
printf " -h, --help Display this help message\n"
exit 0
}
# Handle --help explicitly since getopts doesn't handle long options
if [ "$1" = "--help" ]; then
usage
fi
# Parse arguments with getopts
while getopts "k:p:h" opt; do
case ${opt} in
k)
KEY="$OPTARG"
;;
p)
PORT="$OPTARG"
;;
h)
usage
;;
\?)
echo "Invalid option: -$OPTARG" >&2
usage
;;
:)
echo "Option -$OPTARG requires an argument." >&2
usage
;;
esac
done
# Check if brew is installed, prompt to install if not
if ! command -v brew &>/dev/null; then
read -p "Homebrew is not installed. Would you like to install it now? (y/n): " install_brew
if [[ $install_brew =~ ^[Yy]$ ]]; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Verify installation was successful
if ! command -v brew &>/dev/null; then
echo "Homebrew installation failed. Please install manually and try again."
exit 1
fi
echo "Homebrew installed successfully."
else
echo "Homebrew is required. Please install Homebrew and try again."
exit 1
fi
fi
if [ -z "$KEY" ]; then
read -p "Enter SSH key: " KEY
fi
mkdir -p ~/.config/beszel ~/.cache/beszel
echo "KEY=\"$KEY\"" >~/.config/beszel/beszel-agent.env
echo "LISTEN=$PORT" >>~/.config/beszel/beszel-agent.env
brew tap henrygd/beszel
brew install beszel-agent
brew services start beszel-agent
printf "\nCheck status: brew services info beszel-agent\n"
echo "Stop: brew services stop beszel-agent"
echo "Start: brew services start beszel-agent"
echo "Restart: brew services restart beszel-agent"
echo "Upgrade: brew upgrade beszel-agent"
echo "Uninstall: brew uninstall beszel-agent"
echo "View logs in ~/.cache/beszel/beszel-agent.log"
printf "Change environment variables in ~/.config/beszel/beszel-agent.env\n"

View File

@@ -0,0 +1,581 @@
param (
[switch]$Elevated,
[Parameter(Mandatory=$true)]
[string]$Key,
[int]$Port = 45876,
[string]$AgentPath = "",
[string]$NSSMPath = ""
)
# Check if key is provided or empty
if ([string]::IsNullOrWhiteSpace($Key)) {
Write-Host "ERROR: SSH Key is required." -ForegroundColor Red
Write-Host "Usage: .\install-agent.ps1 -Key 'your-ssh-key-here' [-Port port-number]" -ForegroundColor Yellow
exit 1
}
# Stop on first error
$ErrorActionPreference = "Stop"
#region Utility Functions
# Function to check if running as admin
function Test-Admin {
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
# Function to check if a command exists
function Test-CommandExists {
param (
[Parameter(Mandatory=$true)]
[string]$Command
)
return (Get-Command $Command -ErrorAction SilentlyContinue)
}
# Function to find beszel-agent in common installation locations
function Find-BeszelAgent {
# First check if it's in PATH
$agentCmd = Get-Command "beszel-agent" -ErrorAction SilentlyContinue
if ($agentCmd) {
return $agentCmd.Source
}
# Common installation paths to check
$commonPaths = @(
"$env:USERPROFILE\scoop\apps\beszel-agent\current\beszel-agent.exe",
"$env:ProgramData\scoop\apps\beszel-agent\current\beszel-agent.exe",
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"$env:ProgramFiles\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"${env:ProgramFiles(x86)}\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
"$env:ProgramFiles\beszel-agent\beszel-agent.exe",
"$env:ProgramFiles(x86)\beszel-agent\beszel-agent.exe",
"$env:SystemDrive\Users\*\scoop\apps\beszel-agent\current\beszel-agent.exe"
)
foreach ($path in $commonPaths) {
# Handle wildcard paths
if ($path.Contains("*")) {
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
if ($foundPaths) {
return $foundPaths[0].FullName
}
} else {
if (Test-Path $path) {
return $path
}
}
}
return $null
}
# Function to find NSSM in common installation locations
function Find-NSSM {
# First check if it's in PATH
$nssmCmd = Get-Command "nssm" -ErrorAction SilentlyContinue
if ($nssmCmd) {
return $nssmCmd.Source
}
# Common installation paths to check
$commonPaths = @(
"$env:USERPROFILE\scoop\apps\nssm\current\nssm.exe",
"$env:ProgramData\scoop\apps\nssm\current\nssm.exe",
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"$env:ProgramFiles\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"${env:ProgramFiles(x86)}\WinGet\Packages\NSSM.NSSM*\nssm.exe",
"$env:SystemDrive\Users\*\scoop\apps\nssm\current\nssm.exe"
)
foreach ($path in $commonPaths) {
# Handle wildcard paths
if ($path.Contains("*")) {
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
if ($foundPaths) {
return $foundPaths[0].FullName
}
} else {
if (Test-Path $path) {
return $path
}
}
}
return $null
}
#endregion
#region Installation Methods
# Function to install Scoop
function Install-Scoop {
Write-Host "Installing Scoop..."
# Check if running as admin - Scoop should not be installed as admin
if (Test-Admin) {
throw "Scoop cannot be installed with administrator privileges. Please run this script as a regular user first to install Scoop and beszel-agent, then run as admin to configure the service."
}
try {
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
if (-not (Test-CommandExists "scoop")) {
throw "Failed to install Scoop - command not available after installation"
}
Write-Host "Scoop installed successfully."
}
catch {
throw "Failed to install Scoop: $($_.Exception.Message)"
}
}
# Function to install Git via Scoop
function Install-Git {
if (Test-CommandExists "git") {
Write-Host "Git is already installed."
return
}
Write-Host "Installing Git..."
scoop install git
if (-not (Test-CommandExists "git")) {
throw "Failed to install Git"
}
}
# Function to install NSSM
function Install-NSSM {
param (
[string]$Method = "Scoop" # Default to Scoop method
)
if (Test-CommandExists "nssm") {
Write-Host "NSSM is already installed."
return
}
Write-Host "Installing NSSM..."
if ($Method -eq "Scoop") {
scoop install nssm
}
elseif ($Method -eq "WinGet") {
winget install -e --id NSSM.NSSM --accept-source-agreements --accept-package-agreements
# Refresh PATH environment variable to make NSSM available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
}
else {
throw "Unsupported installation method: $Method"
}
if (-not (Test-CommandExists "nssm")) {
throw "Failed to install NSSM"
}
}
# Function to install beszel-agent with Scoop
function Install-BeszelAgentWithScoop {
Write-Host "Adding beszel bucket..."
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
Write-Host "Installing / updating beszel-agent..."
scoop install beszel-agent | Out-Null
if (-not (Test-CommandExists "beszel-agent")) {
throw "Failed to install beszel-agent"
}
return $(Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe")
}
# Function to install beszel-agent with WinGet
function Install-BeszelAgentWithWinGet {
Write-Host "Installing / updating beszel-agent..."
# Temporarily change ErrorActionPreference to allow WinGet to complete and show output
$originalErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# Use call operator (&) and capture exit code properly
& winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
$wingetExitCode = $LASTEXITCODE
# Restore original ErrorActionPreference
$ErrorActionPreference = $originalErrorActionPreference
# WinGet exit codes:
# 0 = Success
# -1978335212 (0x8A150014) = No applicable upgrade found (package is up to date)
# -1978335189 (0x8A15002B) = Another "no upgrade needed" variant
# Other codes indicate actual errors
if ($wingetExitCode -eq -1978335212 -or $wingetExitCode -eq -1978335189) {
Write-Host "Package is already up to date." -ForegroundColor Green
} elseif ($wingetExitCode -ne 0) {
Write-Host "WinGet exit code: $wingetExitCode" -ForegroundColor Yellow
}
# Refresh PATH environment variable to make beszel-agent available in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
# Find the path to the beszel-agent executable
$agentPath = (Get-Command beszel-agent -ErrorAction SilentlyContinue).Source
if (-not $agentPath) {
throw "Could not find beszel-agent executable path after installation"
}
return $agentPath
}
# Function to install using Scoop
function Install-WithScoop {
param (
[string]$Key,
[int]$Port
)
try {
# Ensure Scoop is installed
if (-not (Test-CommandExists "scoop")) {
Install-Scoop | Out-Null
}
else {
Write-Host "Scoop is already installed."
}
# Install Git (required for Scoop buckets)
Install-Git | Out-Null
# Install NSSM
Install-NSSM -Method "Scoop" | Out-Null
# Install beszel-agent
$agentPath = Install-BeszelAgentWithScoop
return $agentPath
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
}
}
# Function to install using WinGet
function Install-WithWinGet {
param (
[string]$Key,
[int]$Port
)
try {
# Install NSSM
Install-NSSM -Method "WinGet" | Out-Null
# Install beszel-agent
$agentPath = Install-BeszelAgentWithWinGet
return $agentPath
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
exit 1
}
}
#endregion
#region Service Configuration
# Function to install and configure the NSSM service
function Install-NSSMService {
param (
[Parameter(Mandatory=$true)]
[string]$AgentPath,
[Parameter(Mandatory=$true)]
[string]$Key,
[Parameter(Mandatory=$true)]
[int]$Port,
[string]$NSSMPath = ""
)
Write-Host "Installing beszel-agent service..."
# Determine the NSSM executable to use
$nssmCommand = "nssm"
if ($NSSMPath -and (Test-Path $NSSMPath)) {
$nssmCommand = $NSSMPath
Write-Host "Using NSSM from: $NSSMPath"
} elseif (-not (Test-CommandExists "nssm")) {
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
}
# Check if service already exists
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
if ($existingService) {
Write-Host "Service already exists. Stopping and removing existing service..."
try {
& $nssmCommand stop beszel-agent
& $nssmCommand remove beszel-agent confirm
} catch {
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
& $nssmCommand install beszel-agent $AgentPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to install beszel-agent service"
}
Write-Host "Configuring service environment variables..."
& $nssmCommand set beszel-agent AppEnvironmentExtra "+KEY=$Key"
& $nssmCommand set beszel-agent AppEnvironmentExtra "+PORT=$Port"
# Configure log files
$logDir = "$env:ProgramData\beszel-agent\logs"
if (-not (Test-Path $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
}
$logFile = "$logDir\beszel-agent.log"
& $nssmCommand set beszel-agent AppStdout $logFile
& $nssmCommand set beszel-agent AppStderr $logFile
}
# Function to configure firewall rules
function Configure-Firewall {
param (
[Parameter(Mandatory=$true)]
[int]$Port
)
# Create a firewall rule if it doesn't exist
$ruleName = "Allow beszel-agent"
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
# Remove existing rule if found
if ($existingRule) {
Write-Host "Removing existing firewall rule..."
try {
Remove-NetFirewallRule -DisplayName $ruleName
Write-Host "Existing firewall rule removed successfully."
} catch {
Write-Host "Warning: Failed to remove existing firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# Create new rule with current settings
Write-Host "Creating firewall rule for beszel-agent on port $Port..."
try {
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Allow -Protocol TCP -LocalPort $Port
Write-Host "Firewall rule created successfully."
} catch {
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
}
}
# Function to start and monitor the service
function Start-BeszelAgentService {
param (
[string]$NSSMPath = ""
)
Write-Host "Starting beszel-agent service..."
# Determine the NSSM executable to use
$nssmCommand = "nssm"
if ($NSSMPath -and (Test-Path $NSSMPath)) {
$nssmCommand = $NSSMPath
} elseif (-not (Test-CommandExists "nssm")) {
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
}
& $nssmCommand start beszel-agent
$startResult = $LASTEXITCODE
# Only enter the status check loop if the NSSM start command failed
if ($startResult -ne 0) {
Write-Host "NSSM start command returned error code: $startResult" -ForegroundColor Yellow
Write-Host "This could be due to 'SERVICE_START_PENDING' state. Checking service status..."
# Allow up to 10 seconds for the service to start, checking every second
$maxWaitTime = 10 # seconds
$elapsedTime = 0
$serviceStarted = $false
while (-not $serviceStarted -and $elapsedTime -lt $maxWaitTime) {
Start-Sleep -Seconds 1
$elapsedTime += 1
$serviceStatus = & $nssmCommand status beszel-agent
if ($serviceStatus -eq "SERVICE_RUNNING") {
$serviceStarted = $true
Write-Host "Success! The beszel-agent service is now running." -ForegroundColor Green
}
elseif ($serviceStatus -like "*PENDING*") {
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
}
else {
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
break
}
}
if (-not $serviceStarted) {
Write-Host "Service did not reach running state." -ForegroundColor Yellow
Write-Host "You can check status manually with 'nssm status beszel-agent'" -ForegroundColor Yellow
}
} else {
# NSSM start command was successful
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
}
}
#endregion
#region Main Script Execution
# Check if we're running as admin
$isAdmin = Test-Admin
try {
# First: Install the agent (doesn't require admin)
if (-not $AgentPath) {
# Check for problematic case: running as admin and need Scoop
if ($isAdmin -and -not (Test-CommandExists "scoop") -and -not (Test-CommandExists "winget")) {
Write-Host "ERROR: You're running as administrator but neither Scoop nor WinGet is available." -ForegroundColor Red
Write-Host "Scoop should be installed without admin privileges." -ForegroundColor Red
Write-Host ""
Write-Host "Please either:" -ForegroundColor Yellow
Write-Host "1. Run this script again without administrator privileges" -ForegroundColor Yellow
Write-Host "2. Install WinGet and run this script again" -ForegroundColor Yellow
exit 1
}
if (Test-CommandExists "scoop") {
Write-Host "Using Scoop for installation..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
elseif (Test-CommandExists "winget") {
Write-Host "Using WinGet for installation..."
$AgentPath = Install-WithWinGet -Key $Key -Port $Port
}
else {
Write-Host "Neither Scoop nor WinGet is installed. Installing Scoop..."
$AgentPath = Install-WithScoop -Key $Key -Port $Port
}
}
if (-not $AgentPath) {
throw "Could not find beszel-agent executable. Make sure it was properly installed."
}
# Find NSSM path if not already provided
if (-not $NSSMPath) {
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
# If we still don't have NSSM, try to install it if we have package managers
if (-not $NSSMPath) {
if (Test-CommandExists "winget") {
Write-Host "NSSM not found. Attempting to install via WinGet..."
try {
Install-NSSM -Method "WinGet"
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
} catch {
Write-Host "Failed to install NSSM via WinGet: $($_.Exception.Message)" -ForegroundColor Yellow
}
} elseif (Test-CommandExists "scoop") {
Write-Host "NSSM not found. Attempting to install via Scoop..."
try {
Install-NSSM -Method "Scoop"
$NSSMPath = Find-NSSM
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
}
} catch {
Write-Host "Failed to install NSSM via Scoop: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
# Final check - if we still don't have NSSM and we're admin, we have a problem
if (-not $NSSMPath -and ($isAdmin -or $Elevated)) {
throw "NSSM is required for service installation but was not found and could not be installed. Please install NSSM manually or run as a regular user to install it."
}
}
}
# Second: If we need admin rights for service installation and we don't have them, relaunch
if (-not $isAdmin -and -not $Elevated) {
Write-Host "Admin privileges required for service installation. Relaunching as admin..." -ForegroundColor Yellow
Write-Host "Check service status with 'nssm status beszel-agent'"
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
# Prepare arguments for the elevated script
$argumentList = @(
"-ExecutionPolicy", "Bypass",
"-File", "`"$PSCommandPath`"",
"-Elevated",
"-Key", "`"$Key`"",
"-Port", $Port,
"-AgentPath", "`"$AgentPath`""
)
# Add NSSMPath if we found it
if ($NSSMPath) {
$argumentList += "-NSSMPath"
$argumentList += "`"$NSSMPath`""
}
# Relaunch the script with the -Elevated switch and pass parameters
Start-Process powershell.exe -Verb RunAs -ArgumentList $argumentList
exit
}
# Third: If we have admin rights, install service and configure firewall
if ($isAdmin -or $Elevated) {
# Install the service
Install-NSSMService -AgentPath $AgentPath -Key $Key -Port $Port -NSSMPath $NSSMPath
# Configure firewall
Configure-Firewall -Port $Port
# Start the service
Start-BeszelAgentService -NSSMPath $NSSMPath
# Pause to see results if this is an elevated window
if ($Elevated) {
Write-Host "Press any key to exit..." -ForegroundColor Cyan
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
}
}
catch {
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
# Pause if this is likely a new window
if ($Elevated -or (-not $isAdmin)) {
Write-Host "Press any key to exit..." -ForegroundColor Red
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
}
exit 1
}
#endregion

View File

@@ -8,7 +8,53 @@ is_openwrt() {
cat /etc/os-release | grep -q "OpenWrt"
}
# Function to ensure the proxy URL ends with a /
# If SELinux is enabled, set the context of the binary
set_selinux_context() {
# Check if SELinux is enabled and in enforcing or permissive mode
if command -v getenforce >/dev/null 2>&1; then
SELINUX_MODE=$(getenforce)
if [ "$SELINUX_MODE" != "Disabled" ]; then
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
# First try to set persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
echo "Attempting to set persistent SELinux context..."
if semanage fcontext -a -t bin_t "/opt/beszel-agent/beszel-agent" >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1
else
echo "Warning: Failed to set persistent context, falling back to temporary context."
fi
fi
# Fall back to chcon if semanage failed or isn't available
if command -v chcon >/dev/null 2>&1; then
# Set context for both the directory and binary
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: Failed to set SELinux context for binary."
chcon -R -t bin_t /opt/beszel-agent || echo "Warning: Failed to set SELinux context for directory."
else
if [ "$SELINUX_MODE" = "Enforcing" ]; then
echo "Warning: SELinux is in enforcing mode but chcon command not found. The service may fail to start."
echo "Consider installing the policycoreutils package or temporarily setting SELinux to permissive mode."
else
echo "Warning: SELinux is in permissive mode but chcon command not found."
fi
fi
fi
fi
}
# Clean up SELinux contexts if they were set
cleanup_selinux_context() {
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "Cleaning up SELinux contexts..."
# Remove persistent context if semanage is available
if command -v semanage >/dev/null 2>&1; then
semanage fcontext -d "/opt/beszel-agent/beszel-agent" 2>/dev/null || true
fi
fi
}
# Ensure the proxy URL ends with a /
ensure_trailing_slash() {
if [ -n "$1" ]; then
case "$1" in
@@ -20,13 +66,14 @@ ensure_trailing_slash() {
fi
}
# Define default values
# Default values
PORT=45876
UNINSTALL=false
GITHUB_URL="https://github.com"
GITHUB_API_URL="https://api.github.com" # not blocked in China currently
GITHUB_PROXY_URL=""
KEY=""
AUTO_UPDATE_FLAG="" # empty string means prompt, "true" means auto-enable, "false" means skip
# Check for help flag
case "$1" in
@@ -37,8 +84,10 @@ case "$1" in
printf " -k : SSH key (required, or interactive if not provided)\n"
printf " -p : Port (default: $PORT)\n"
printf " -u : Uninstall Beszel Agent\n"
printf " --china-mirrors [URL] : Use GitHub proxy (gh.beszel.dev) to resolve network timeout issues in mainland China\n"
printf " optional: specify a custom proxy URL, e.g., \"https://ghfast.top\"\n"
printf " --auto-update [VALUE] : Control automatic daily updates\n"
printf " VALUE can be true (enable) or false (disable). If not specified, will prompt.\n"
printf " --china-mirrors [URL] : Use GitHub proxy to resolve network timeout issues in mainland China\n"
printf " URL: optional custom proxy URL (default: https://gh.beszel.dev)\n"
printf " -h, --help : Display this help message\n"
exit 0
;;
@@ -84,17 +133,50 @@ while [ $# -gt 0 ]; do
-u)
UNINSTALL=true
;;
--china-mirrors)
if [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
# use cstom proxy URL if provided
--china-mirrors*)
# Check if there's a value after the = sign
if echo "$1" | grep -q "="; then
# Extract the value after =
CUSTOM_PROXY=$(echo "$1" | cut -d'=' -f2)
if [ -n "$CUSTOM_PROXY" ]; then
GITHUB_PROXY_URL="$CUSTOM_PROXY"
GITHUB_URL="$(ensure_trailing_slash "$CUSTOM_PROXY")https://github.com"
else
GITHUB_PROXY_URL="https://gh.beszel.dev"
GITHUB_URL="$GITHUB_PROXY_URL"
fi
elif [ "$2" != "" ] && ! echo "$2" | grep -q '^-'; then
# use custom proxy URL provided as next argument
GITHUB_PROXY_URL="$2"
GITHUB_URL="$(ensure_trailing_slash "$2")https://github.com"
shift
else
# No value specified, use default
GITHUB_PROXY_URL="https://gh.beszel.dev"
GITHUB_URL="$GITHUB_PROXY_URL"
fi
;;
--auto-update*)
# Check if there's a value after the = sign
if echo "$1" | grep -q "="; then
# Extract the value after =
AUTO_UPDATE_VALUE=$(echo "$1" | cut -d'=' -f2)
if [ "$AUTO_UPDATE_VALUE" = "true" ]; then
AUTO_UPDATE_FLAG="true"
elif [ "$AUTO_UPDATE_VALUE" = "false" ]; then
AUTO_UPDATE_FLAG="false"
else
echo "Invalid value for --auto-update flag: $AUTO_UPDATE_VALUE. Using default (prompt)."
fi
elif [ "$2" = "true" ] || [ "$2" = "false" ]; then
# Value provided as next argument
AUTO_UPDATE_FLAG="$2"
shift
else
# No value specified, use true
AUTO_UPDATE_FLAG="true"
fi
;;
*)
echo "Invalid option: $1" >&2
exit 1
@@ -105,6 +187,9 @@ done
# Uninstall process
if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files
cleanup_selinux_context
if is_alpine; then
echo "Stopping and disabling the agent service..."
rc-service beszel-agent stop
@@ -179,7 +264,7 @@ if [ -n "$GITHUB_PROXY_URL" ]; then
fi
fi
# Function to check if a package is installed
# Check if a package is installed
package_installed() {
command -v "$1" >/dev/null 2>&1
}
@@ -231,18 +316,27 @@ fi
# Create a dedicated user for the service if it doesn't exist
if is_alpine; then
if ! id -u beszel >/dev/null 2>&1; then
echo "Creating a dedicated group for the Beszel Agent service..."
addgroup beszel
echo "Creating a dedicated user for the Beszel Agent service..."
adduser -D -H -s /sbin/nologin beszel
adduser -S -D -H -s /sbin/nologin -G beszel beszel
fi
# Add the user to the docker group to allow access to the Docker socket
addgroup beszel docker
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker; then
echo "Adding besel to docker group"
usermod -aG docker beszel
fi
else
if ! id -u beszel >/dev/null 2>&1; then
echo "Creating a dedicated user for the Beszel Agent service..."
useradd -M -s /bin/false beszel
useradd --system --home-dir /nonexistent --shell /bin/false beszel
fi
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker; then
echo "Adding besel to docker group"
usermod -aG docker beszel
fi
# Add the user to the docker group to allow access to the Docker socket
usermod -aG docker beszel
fi
# Create the directory for the Beszel Agent
@@ -298,9 +392,23 @@ mv beszel-agent /opt/beszel-agent/beszel-agent
chown beszel:beszel /opt/beszel-agent/beszel-agent
chmod 755 /opt/beszel-agent/beszel-agent
# Set SELinux context if needed
set_selinux_context
# Cleanup
rm -rf "$TEMP_DIR"
# Check for NVIDIA GPUs and grant device permissions for systemd service
detect_nvidia_devices() {
local devices=""
for i in /dev/nvidia*; do
if [ -e "$i" ]; then
devices="${devices}DeviceAllow=$i rw\n"
fi
done
echo "$devices"
}
# Modify service installation part, add Alpine check before systemd service creation
if is_alpine; then
echo "Creating OpenRC service for Alpine Linux..."
@@ -348,8 +456,14 @@ EOF
fi
# Auto-update service for Alpine
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
@@ -432,8 +546,16 @@ EOF
service beszel-agent restart
# Auto-update service for OpenWRT using a crontab job
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
sleep 1 # give time for the service to start
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
sleep 1 # give time for the service to start
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
@@ -458,6 +580,10 @@ EOF
else
# Original systemd service installation code
echo "Creating the systemd service for the agent..."
# Detect NVIDIA devices and grant device permissions
NVIDIA_DEVICES=$(detect_nvidia_devices)
cat >/etc/systemd/system/beszel-agent.service <<EOF
[Unit]
Description=Beszel Agent Service
@@ -478,7 +604,6 @@ StateDirectory=beszel-agent
KeyringMode=private
LockPersonality=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectClock=yes
ProtectHome=read-only
ProtectHostname=yes
@@ -488,6 +613,8 @@ RemoveIPC=yes
RestrictSUIDSGID=true
SystemCallArchitectures=native
$(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${NVIDIA_DEVICES}"; fi)
[Install]
WantedBy=multi-user.target
EOF
@@ -498,9 +625,48 @@ EOF
systemctl enable beszel-agent.service
systemctl start beszel-agent.service
# Create the update script
echo "Creating the update script..."
cat >/opt/beszel-agent/run-update.sh <<'EOF'
#!/bin/sh
set -e
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
echo "Update found, checking SELinux context."
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
echo "SELinux enabled, applying context..."
if command -v chcon >/dev/null 2>&1; then
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: chcon command failed to apply context."
fi
if command -v restorecon >/dev/null 2>&1; then
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1 || echo "Warning: restorecon command failed to apply context."
fi
fi
echo "Restarting beszel-agent service..."
systemctl restart beszel-agent
echo "Update process finished."
else
echo "No updates found or applied."
fi
exit 0
EOF
chown root:root /opt/beszel-agent/run-update.sh
chmod +x /opt/beszel-agent/run-update.sh
# Prompt for auto-update setup
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
AUTO_UPDATE="y"
sleep 1 # give time for the service to start
elif [ "$AUTO_UPDATE_FLAG" = "false" ]; then
AUTO_UPDATE="n"
sleep 1 # give time for the service to start
else
printf "\nWould you like to enable automatic daily updates for beszel-agent? (y/n): "
read AUTO_UPDATE
fi
case "$AUTO_UPDATE" in
[Yy]*)
echo "Setting up daily automatic updates for beszel-agent..."
@@ -513,7 +679,7 @@ Wants=beszel-agent.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/opt/beszel-agent/beszel-agent update | grep -q "Successfully updated" && (echo "Update found, restarting beszel-agent" && systemctl restart beszel-agent) || echo "No updates found"'
ExecStart=/opt/beszel-agent/run-update.sh
EOF
# Create systemd timer for the daily update