Compare commits

..

72 Commits

Author SHA1 Message Date
Yifan
16d5ec267d S.M.A.R.T support (#614)
* add agent smart support

* refactor(system): update JSON tags in SmartData struct

* refactor(agent): use serial number as the key of SmartDataMap

Updated the SmartManager's methods to use the device's serial number as the key in the SmartDataMap instead of the device name.

* refactor: use raw values in smart attributes for nvme devices

* feat: add S.M.A.R.T. data display in web ui

Introduced a new Disks tab in the SystemDetail component to display disk information and S.M.A.R.T. data. The tab includes a table for visualizing disk attributes and their statuses.

Also added SmartData and SmartAttribute interfaces to support the new functionality.
2025-10-24 09:49:04 -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
75 changed files with 11299 additions and 7785 deletions

View File

@@ -3,7 +3,7 @@ name: Make release and binaries
on:
push:
tags:
- '*'
- 'v*'
permissions:
contents: write

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,37 @@ 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
smartManager *SmartManager // Manages SMART data
}
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 +50,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()
@@ -86,6 +63,12 @@ func NewAgent() *Agent {
agent.gpuManager = gm
}
if sm, err := NewSmartManager(); err != nil {
slog.Debug("SMART", "err", err)
} else {
agent.smartManager = sm
}
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(""))
@@ -119,11 +102,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]
@@ -283,6 +276,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 +291,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,
@@ -284,7 +283,7 @@ func TestParseJetsonData(t *testing.T) {
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 +291,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)
@@ -443,7 +428,7 @@ echo "test"`
}
return nil
},
wantNvidiaSmi: true,
wantNvidiaSmi: false,
wantRocmSmi: true,
wantTegrastats: true,
wantErr: false,

View File

@@ -0,0 +1,163 @@
package agent
import (
"beszel/internal/entities/system"
"context"
"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)
}
// 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
// get sensor data
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
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
if a.sensorConfig.primarySensor == "" {
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
} else if a.sensorConfig.primarySensor == sensorName {
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// 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,456 @@
//go:build testing
// +build testing
package agent
import (
"context"
"os"
"testing"
"github.com/shirou/gopsutil/v4/common"
"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)
})
}

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

@@ -0,0 +1,304 @@
package agent
import (
"beszel/internal/entities/smart"
"beszel/internal/entities/system"
"context"
"encoding/json"
"fmt"
"os/exec"
"reflect"
"sync"
"time"
"golang.org/x/exp/slog"
)
// SmartManager manages data collection for SMART devices
// TODO: add retry argument
// TODO: add timeout argument
type SmartManager struct {
SmartDataMap map[string]*system.SmartData
SmartDevices []*DeviceInfo
mutex sync.Mutex
}
type scanOutput struct {
Devices []struct {
Name string `json:"name"`
Type string `json:"type"`
InfoName string `json:"info_name"`
Protocol string `json:"protocol"`
} `json:"devices"`
}
type DeviceInfo struct {
Name string `json:"name"`
Type string `json:"type"`
InfoName string `json:"info_name"`
Protocol string `json:"protocol"`
}
var errNoValidSmartData = fmt.Errorf("no valid GPU data found") // Error for missing data
// Starts the SmartManager
func (sm *SmartManager) Start() {
sm.SmartDataMap = make(map[string]*system.SmartData)
for {
err := sm.ScanDevices()
if err != nil {
slog.Warn("smartctl scan failed, stopping", "err", err)
return
}
// TODO: add retry logic
for _, deviceInfo := range sm.SmartDevices {
err := sm.CollectSmart(deviceInfo)
if err != nil {
slog.Warn("smartctl collect failed, stopping", "err", err)
return
}
}
// Sleep for 10 seconds before next scan
time.Sleep(10 * time.Second)
}
}
// GetCurrentData returns the current SMART data
func (sm *SmartManager) GetCurrentData() map[string]system.SmartData {
sm.mutex.Lock()
defer sm.mutex.Unlock()
result := make(map[string]system.SmartData)
for key, value := range sm.SmartDataMap {
result[key] = *value
}
return result
}
// ScanDevices scans for SMART devices
// Scan devices using `smartctl --scan -j`
// If scan fails, return error
// If scan succeeds, parse the output and update the SmartDevices slice
func (sm *SmartManager) ScanDevices() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
output, err := cmd.Output()
if err != nil {
return err
}
hasValidData := sm.parseScan(output)
if !hasValidData {
return errNoValidSmartData
}
return nil
}
// CollectSmart collects SMART data for a device
// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX`
// If collect fails, return error
// If collect succeeds, parse the output and update the SmartDataMap
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "smartctl", "--all", "-j", deviceInfo.Name)
output, err := cmd.Output()
if err != nil {
return err
}
hasValidData := false
if deviceInfo.Type == "scsi" {
// parse scsi devices
hasValidData = sm.parseSmartForScsi(output)
} else if deviceInfo.Type == "nvme" {
// parse nvme devices
hasValidData = sm.parseSmartForNvme(output)
}
if !hasValidData {
return errNoValidSmartData
}
return nil
}
// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice
func (sm *SmartManager) parseScan(output []byte) bool {
sm.mutex.Lock()
defer sm.mutex.Unlock()
sm.SmartDevices = make([]*DeviceInfo, 0)
scan := &scanOutput{}
if err := json.Unmarshal(output, scan); err != nil {
fmt.Printf("Failed to parse JSON: %v\n", err)
return false
}
scannedDeviceNameMap := make(map[string]bool)
for _, device := range scan.Devices {
deviceInfo := &DeviceInfo{
Name: device.Name,
Type: device.Type,
InfoName: device.InfoName,
Protocol: device.Protocol,
}
sm.SmartDevices = append(sm.SmartDevices, deviceInfo)
scannedDeviceNameMap[device.Name] = true
}
// remove devices that are not in the scan
for key := range sm.SmartDataMap {
if _, ok := scannedDeviceNameMap[key]; !ok {
delete(sm.SmartDataMap, key)
}
}
devicesString := ""
for _, device := range sm.SmartDevices {
devicesString += device.Name + " "
}
return true
}
// parseSmartForScsi parses the output of smartctl --all -j /dev/sdX and updates the SmartDataMap
func (sm *SmartManager) parseSmartForScsi(output []byte) bool {
data := &smart.SmartInfoForSata{}
if err := json.Unmarshal(output, &data); err != nil {
return false
}
sm.mutex.Lock()
defer sm.mutex.Unlock()
// get device name (e.g. /dev/sda)
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
if _, ok := sm.SmartDataMap[keyName]; !ok {
sm.SmartDataMap[keyName] = &system.SmartData{}
}
// update SmartData
smartData := sm.SmartDataMap[keyName]
smartData.ModelFamily = data.ModelFamily
smartData.ModelName = data.ModelName
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
if data.SmartStatus.Passed {
smartData.SmartStatus = "PASSED"
} else {
smartData.SmartStatus = "FAILED"
}
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
// update SmartAttributes
smartData.Attributes = make([]*system.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
for _, attr := range data.AtaSmartAttributes.Table {
smartAttr := &system.SmartAttribute{
Id: attr.ID,
Name: attr.Name,
Value: attr.Value,
Worst: attr.Worst,
Threshold: attr.Thresh,
RawValue: attr.Raw.Value,
RawString: attr.Raw.String,
Flags: attr.Flags.String,
WhenFailed: attr.WhenFailed,
}
smartData.Attributes = append(smartData.Attributes, smartAttr)
}
smartData.Temperature = data.Temperature.Current
sm.SmartDataMap[keyName] = smartData
return true
}
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
func (sm *SmartManager) parseSmartForNvme(output []byte) bool {
data := &smart.SmartInfoForNvme{}
if err := json.Unmarshal(output, &data); err != nil {
return false
}
sm.mutex.Lock()
defer sm.mutex.Unlock()
// get device name (e.g. /dev/nvme0)
keyName := data.SerialNumber
// if device does not exist in SmartDataMap, initialize it
if _, ok := sm.SmartDataMap[keyName]; !ok {
sm.SmartDataMap[keyName] = &system.SmartData{}
}
// update SmartData
smartData := sm.SmartDataMap[keyName]
smartData.ModelName = data.ModelName
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
if data.SmartStatus.Passed {
smartData.SmartStatus = "PASSED"
} else {
smartData.SmartStatus = "FAILED"
}
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
v := reflect.ValueOf(data.NVMeSmartHealthInformationLog)
t := v.Type()
smartData.Attributes = make([]*system.SmartAttribute, 0, v.NumField())
// nvme attributes does not follow the same format as ata attributes,
// so we have to manually iterate over the fields and update SmartAttributes
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
value := v.Field(i)
key := field.Name
val := value.Interface()
// drop non int values
if _, ok := val.(int); !ok {
continue
}
smartAttr := &system.SmartAttribute{
Name: key,
RawValue: val.(int),
}
smartData.Attributes = append(smartData.Attributes, smartAttr)
}
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
sm.SmartDataMap[keyName] = smartData
return true
}
// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() error {
if _, err := exec.LookPath("smartctl"); err == nil {
return nil
}
return fmt.Errorf("no smartctl found - install smartctl")
}
// NewGPUManager creates and initializes a new GPUManager
func NewSmartManager() (*SmartManager, error) {
var sm SmartManager
if err := sm.detectSmartctl(); err != nil {
return nil, err
}
go sm.Start()
return &sm, nil
}

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,35 @@ 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
}
}
}
if a.smartManager != nil {
if smartData := a.smartManager.GetCurrentData(); len(smartData) > 0 {
systemStats.SmartData = smartData
if systemStats.Temperatures == nil {
systemStats.Temperatures = make(map[string]float64, len(a.smartManager.SmartDataMap))
}
for key, value := range a.smartManager.SmartDataMap {
systemStats.Temperatures[key] = float64(value.Temperature)
}
}
}
@@ -224,52 +260,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

@@ -0,0 +1,269 @@
package smart
type SmartInfoForSata struct {
JSONFormatVersion []int `json:"json_format_version"`
Smartctl struct {
Version []int `json:"version"`
SvnRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
} `json:"smartctl"`
Device struct {
Name string `json:"name"`
InfoName string `json:"info_name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
} `json:"device"`
ModelFamily string `json:"model_family"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
Wwn struct {
Naa int `json:"naa"`
Oui int `json:"oui"`
ID int `json:"id"`
} `json:"wwn"`
FirmwareVersion string `json:"firmware_version"`
UserCapacity struct {
Blocks uint64 `json:"blocks"`
Bytes uint64 `json:"bytes"`
} `json:"user_capacity"`
LogicalBlockSize int `json:"logical_block_size"`
PhysicalBlockSize int `json:"physical_block_size"`
RotationRate int `json:"rotation_rate"`
FormFactor struct {
AtaValue int `json:"ata_value"`
Name string `json:"name"`
} `json:"form_factor"`
Trim struct {
Supported bool `json:"supported"`
} `json:"trim"`
InSmartctlDatabase bool `json:"in_smartctl_database"`
AtaVersion struct {
String string `json:"string"`
MajorValue int `json:"major_value"`
MinorValue int `json:"minor_value"`
} `json:"ata_version"`
SataVersion struct {
String string `json:"string"`
Value int `json:"value"`
} `json:"sata_version"`
InterfaceSpeed struct {
Max struct {
SataValue int `json:"sata_value"`
String string `json:"string"`
UnitsPerSecond int `json:"units_per_second"`
BitsPerUnit int `json:"bits_per_unit"`
} `json:"max"`
Current struct {
SataValue int `json:"sata_value"`
String string `json:"string"`
UnitsPerSecond int `json:"units_per_second"`
BitsPerUnit int `json:"bits_per_unit"`
} `json:"current"`
} `json:"interface_speed"`
LocalTime struct {
TimeT int `json:"time_t"`
Asctime string `json:"asctime"`
} `json:"local_time"`
SmartStatus struct {
Passed bool `json:"passed"`
} `json:"smart_status"`
AtaSmartData struct {
OfflineDataCollection struct {
Status struct {
Value int `json:"value"`
String string `json:"string"`
Passed bool `json:"passed"`
} `json:"status"`
CompletionSeconds int `json:"completion_seconds"`
} `json:"offline_data_collection"`
SelfTest struct {
Status struct {
Value int `json:"value"`
String string `json:"string"`
Passed bool `json:"passed"`
} `json:"status"`
PollingMinutes struct {
Short int `json:"short"`
Extended int `json:"extended"`
} `json:"polling_minutes"`
} `json:"self_test"`
Capabilities struct {
Values []int `json:"values"`
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
SelfTestsSupported bool `json:"self_tests_supported"`
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
ErrorLoggingSupported bool `json:"error_logging_supported"`
GpLoggingSupported bool `json:"gp_logging_supported"`
} `json:"capabilities"`
} `json:"ata_smart_data"`
AtaSctCapabilities struct {
Value int `json:"value"`
ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
FeatureControlSupported bool `json:"feature_control_supported"`
DataTableSupported bool `json:"data_table_supported"`
} `json:"ata_sct_capabilities"`
AtaSmartAttributes struct {
Revision int `json:"revision"`
Table []struct {
ID int `json:"id"`
Name string `json:"name"`
Value int `json:"value"`
Worst int `json:"worst"`
Thresh int `json:"thresh"`
WhenFailed string `json:"when_failed"`
Flags struct {
Value int `json:"value"`
String string `json:"string"`
Prefailure bool `json:"prefailure"`
UpdatedOnline bool `json:"updated_online"`
Performance bool `json:"performance"`
ErrorRate bool `json:"error_rate"`
EventCount bool `json:"event_count"`
AutoKeep bool `json:"auto_keep"`
} `json:"flags"`
Raw struct {
Value int `json:"value"`
String string `json:"string"`
} `json:"raw"`
} `json:"table"`
} `json:"ata_smart_attributes"`
PowerOnTime struct {
Hours int `json:"hours"`
} `json:"power_on_time"`
PowerCycleCount int `json:"power_cycle_count"`
Temperature struct {
Current int `json:"current"`
} `json:"temperature"`
AtaSmartErrorLog struct {
Summary struct {
Revision int `json:"revision"`
Count int `json:"count"`
} `json:"summary"`
} `json:"ata_smart_error_log"`
AtaSmartSelfTestLog struct {
Standard struct {
Revision int `json:"revision"`
Count int `json:"count"`
} `json:"standard"`
} `json:"ata_smart_self_test_log"`
AtaSmartSelectiveSelfTestLog struct {
Revision int `json:"revision"`
Table []struct {
LbaMin int `json:"lba_min"`
LbaMax int `json:"lba_max"`
Status struct {
Value int `json:"value"`
String string `json:"string"`
} `json:"status"`
} `json:"table"`
Flags struct {
Value int `json:"value"`
RemainderScanEnabled bool `json:"remainder_scan_enabled"`
} `json:"flags"`
PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
} `json:"ata_smart_selective_self_test_log"`
}
type SmartInfoForNvme struct {
JSONFormatVersion [2]int `json:"json_format_version"`
Smartctl struct {
Version [2]int `json:"version"`
SVNRevision string `json:"svn_revision"`
PlatformInfo string `json:"platform_info"`
BuildInfo string `json:"build_info"`
Argv []string `json:"argv"`
ExitStatus int `json:"exit_status"`
} `json:"smartctl"`
Device struct {
Name string `json:"name"`
InfoName string `json:"info_name"`
Type string `json:"type"`
Protocol string `json:"protocol"`
} `json:"device"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
FirmwareVersion string `json:"firmware_version"`
NVMePCIVendor struct {
ID int `json:"id"`
SubsystemID int `json:"subsystem_id"`
} `json:"nvme_pci_vendor"`
NVMeIEEEOUIIdentifier int `json:"nvme_ieee_oui_identifier"`
NVMeTotalCapacity int `json:"nvme_total_capacity"`
NVMeUnallocatedCapacity int `json:"nvme_unallocated_capacity"`
NVMeControllerID int `json:"nvme_controller_id"`
NVMeVersion struct {
String string `json:"string"`
Value int `json:"value"`
} `json:"nvme_version"`
NVMeNumberOfNamespaces int `json:"nvme_number_of_namespaces"`
NVMeNamespaces []struct {
ID int `json:"id"`
Size struct {
Blocks int `json:"blocks"`
Bytes int `json:"bytes"`
} `json:"size"`
Capacity struct {
Blocks int `json:"blocks"`
Bytes int `json:"bytes"`
} `json:"capacity"`
Utilization struct {
Blocks int `json:"blocks"`
Bytes int `json:"bytes"`
} `json:"utilization"`
FormattedLBASize int `json:"formatted_lba_size"`
EUI64 struct {
OUI int `json:"oui"`
ExtID int `json:"ext_id"`
} `json:"eui64"`
} `json:"nvme_namespaces"`
UserCapacity struct {
Blocks uint64 `json:"blocks"`
Bytes uint64 `json:"bytes"`
} `json:"user_capacity"`
LogicalBlockSize int `json:"logical_block_size"`
LocalTime struct {
TimeT int64 `json:"time_t"`
Asctime string `json:"asctime"`
} `json:"local_time"`
SmartStatus struct {
Passed bool `json:"passed"`
NVMe struct {
Value int `json:"value"`
} `json:"nvme"`
} `json:"smart_status"`
NVMeSmartHealthInformationLog struct {
CriticalWarning int `json:"critical_warning"`
Temperature int `json:"temperature"`
AvailableSpare int `json:"available_spare"`
AvailableSpareThreshold int `json:"available_spare_threshold"`
PercentageUsed int `json:"percentage_used"`
DataUnitsRead int `json:"data_units_read"`
DataUnitsWritten int `json:"data_units_written"`
HostReads int `json:"host_reads"`
HostWrites int `json:"host_writes"`
ControllerBusyTime int `json:"controller_busy_time"`
PowerCycles int `json:"power_cycles"`
PowerOnHours int `json:"power_on_hours"`
UnsafeShutdowns int `json:"unsafe_shutdowns"`
MediaErrors int `json:"media_errors"`
NumErrLogEntries int `json:"num_err_log_entries"`
WarningTempTime int `json:"warning_temp_time"`
CriticalCompTime int `json:"critical_comp_time"`
TemperatureSensors []int `json:"temperature_sensors"`
} `json:"nvme_smart_health_information_log"`
Temperature struct {
Current int `json:"current"`
} `json:"temperature"`
PowerCycleCount int `json:"power_cycle_count"`
PowerOnTime struct {
Hours int `json:"hours"`
} `json:"power_on_time"`
}

View File

@@ -8,29 +8,30 @@ import (
)
type Stats struct {
Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
MaxDiskReadPs float64 `json:"drm,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty"`
Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
MaxDiskReadPs float64 `json:"drm,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty"`
SmartData map[string]SmartData `json:"sm,omitempty"`
}
type GPUData struct {
@@ -64,6 +65,40 @@ type NetIoStats struct {
Name string
}
type Os uint8
const (
Linux Os = iota
Darwin
Windows
Freebsd
)
type SmartData struct {
ModelFamily string `json:"mf,omitempty"`
ModelName string `json:"mn,omitempty"`
SerialNumber string `json:"sn,omitempty"`
FirmwareVersion string `json:"fv,omitempty"`
Capacity uint64 `json:"c,omitempty"`
SmartStatus string `json:"s,omitempty"`
DiskName string `json:"dn,omitempty"` // something like /dev/sda
DiskType string `json:"dt,omitempty"`
Temperature int `json:"t,omitempty"`
Attributes []*SmartAttribute `json:"a,omitempty"`
}
type SmartAttribute struct {
Id int `json:"id,omitempty"`
Name string `json:"n"`
Value int `json:"v,omitempty"`
Worst int `json:"w,omitempty"`
Threshold int `json:"t,omitempty"`
RawValue int `json:"rv"`
RawString string `json:"rs,omitempty"`
Flags string `json:"f,omitempty"`
WhenFailed string `json:"wf,omitempty"`
}
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
@@ -79,6 +114,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,13 +32,15 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
import { timeTicks } from "d3-time"
import { useLingui } from "@lingui/react/macro"
import { $router, navigate } from "../router"
import { getPagePath } from "@nanostores/router"
import DisksTab from "../tabs/disks-tab"
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const ContainerChart = lazy(() => import("../charts/container-chart"))
@@ -218,7 +230,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 +263,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 +301,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 +335,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)
@@ -426,6 +465,14 @@ export default function SystemDetail({ name }: { name: string }) {
</div>
</Card>
{/* tabs for different views */}
<Tabs defaultValue="systems" className="w-full">
<TabsList>
<TabsTrigger value="systems">Systems</TabsTrigger>
<TabsTrigger value="disks">Disks</TabsTrigger>
</TabsList>
<TabsContent value="systems" className="mt-4">
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<ChartCard
@@ -446,7 +493,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 +514,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 +556,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 +580,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>
@@ -622,6 +670,12 @@ export default function SystemDetail({ name }: { name: string }) {
})}
</div>
)}
</TabsContent>
<TabsContent value="disks" className="mt-4">
<DisksTab smartData={systemStats.at(-1)?.stats.sm} />
</TabsContent>
</Tabs>
</div>
{/* add space for tooltip if more than 12 containers */}
@@ -630,12 +684,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 +702,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

@@ -0,0 +1,631 @@
"use client"
import * as React from "react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Activity, Box, Binary, Container, ChevronDown, Clock, HardDrive, Thermometer, Tags, MoreHorizontal } from "lucide-react"
import { Button } from "../ui/button"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import { Input } from "../ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table"
import { Badge } from "../ui/badge"
import { SmartData, SmartAttribute } from "@/types"
// Column definition for S.M.A.R.T. attributes table
export const smartColumns: ColumnDef<SmartAttribute>[] = [
{
accessorKey: "id",
header: "ID",
cell: ({ row }) => {
const id = row.getValue("id") as number | undefined
return <div className="font-medium">{id || ""}</div>
},
enableSorting: false,
},
{
accessorKey: "n",
header: "Name",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("n")}</div>
),
enableSorting: false,
},
{
accessorKey: "rs",
header: "Value",
cell: ({ row }) => {
// if raw string is not empty, use it, otherwise use raw value
const rawString = row.getValue("rs") as string | undefined
const rawValue = row.original.rv
const displayValue = rawString || rawValue?.toString() || "-"
return <div className="font-mono text-sm">{displayValue}</div>
},
enableSorting: false,
},
{
accessorKey: "v",
header: "Normalized",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("v")}</div>
),
enableSorting: false,
},
{
accessorKey: "w",
header: "Worst",
cell: ({ row }) => {
const worst = row.getValue("w") as number | undefined
return <div>{worst || ""}</div>
},
enableSorting: false,
},
{
accessorKey: "t",
header: "Threshold",
cell: ({ row }) => {
const threshold = row.getValue("t") as number | undefined
return <div>{threshold || ""}</div>
},
enableSorting: false,
},
{
accessorKey: "f",
header: "Flags",
cell: ({ row }) => {
const flags = row.getValue("f") as string | undefined
return <div className="font-mono text-sm">{flags || ""}</div>
},
enableSorting: false,
},
{
accessorKey: "wf",
header: "Failing",
cell: ({ row }) => {
const whenFailed = row.getValue("wf") as string | undefined
return <div className="font-mono text-sm">{whenFailed || ""}</div>
},
enableSorting: false,
},
]
export type DiskInfo = {
device: string
model: string
serialNumber: string
firmwareVersion: string
capacity: string
status: string
temperature: number
deviceType: string
powerOnHours?: number
powerCycles?: number
}
// Function to format capacity display
function formatCapacity(bytes: number): string {
const units = [
{ name: 'PB', size: 1024 ** 5 },
{ name: 'TB', size: 1024 ** 4 },
{ name: 'GB', size: 1024 ** 3 },
{ name: 'MB', size: 1024 ** 2 },
{ name: 'KB', size: 1024 ** 1 },
{ name: 'B', size: 1 }
]
for (const unit of units) {
if (bytes >= unit.size) {
const value = bytes / unit.size
// For bytes, don't show decimals; for other units show one decimal place
const decimals = unit.name === 'B' ? 0 : 1
return `${value.toFixed(decimals)} ${unit.name}`
}
}
return '0 B'
}
// Function to convert SmartData to DiskInfo
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
device: smartData.dn || key,
model: smartData.mn || "Unknown",
serialNumber: smartData.sn || "Unknown",
firmwareVersion: smartData.fv || "Unknown",
capacity: smartData.c ? formatCapacity(smartData.c) : "Unknown",
status: smartData.s || "Unknown",
temperature: smartData.t || 0,
deviceType: smartData.dt || "Unknown",
// These fields need to be extracted from SmartAttribute if available
powerOnHours: smartData.a?.find(attr => attr.n.toLowerCase().includes("poweronhours") || attr.n.toLowerCase().includes("power_on_hours"))?.rv,
powerCycles: smartData.a?.find(attr => attr.n.toLowerCase().includes("power") && attr.n.toLowerCase().includes("cycle"))?.rv,
}))
}
// S.M.A.R.T. details dialog component
function SmartDialog({ disk, smartData }: { disk: DiskInfo; smartData?: SmartData }) {
const [open, setOpen] = React.useState(false)
const smartAttributes = smartData?.a || []
// Find all attributes where when failed is not empty
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '')
const table = useReactTable({
data: smartAttributes,
columns: smartColumns,
getCoreRowModel: getCoreRowModel(),
enableSorting: false,
})
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
View S.M.A.R.T.
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>S.M.A.R.T. Details - {disk.device}</DialogTitle>
<DialogDescription>
S.M.A.R.T. attributes for {disk.model} ({disk.serialNumber})
</DialogDescription>
</DialogHeader>
{smartData?.s && (
<div className={`p-4 rounded-md ${
smartData.s === "PASSED"
? "bg-green-100 dark:bg-green-900 border border-green-200 dark:border-green-800"
: "bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800"
}`}>
<h4 className={`font-semibold ${
smartData.s === "PASSED"
? "text-green-800 dark:text-green-200"
: "text-red-800 dark:text-red-200"
}`}>
S.M.A.R.T. Self-Test: {smartData.s}
</h4>
{failedAttributes.length > 0 && (
<p className="mt-2 text-red-800 dark:text-red-200">
Failed Attributes: {failedAttributes.map(attr => attr.n).join(", ")}
</p>
)}
</div>
)}
<div className="flex-1 overflow-auto">
{smartAttributes.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => {
// Check if the attribute is failed
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
return (
<TableRow
key={row.id}
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No S.M.A.R.T. attributes available for this device.
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
export const columns: ColumnDef<DiskInfo>[] = [
{
accessorKey: "device",
header: () => (
<div className="flex items-center">
<HardDrive className="mr-2 h-4 w-4" />
Device
</div>
),
cell: ({ row }) => (
<div className="font-medium">{row.getValue("device")}</div>
),
enableSorting: false,
},
{
accessorKey: "model",
header: () => (
<div className="flex items-center">
<Box className="mr-2 h-4 w-4" />
Model
</div>
),
cell: ({ row }) => (
<div className="max-w-[200px] truncate" title={row.getValue("model")}>
{row.getValue("model")}
</div>
),
enableSorting: false,
},
{
accessorKey: "capacity",
header: () => (
<div className="flex items-center">
<Container className="mr-2 h-4 w-4" />
Capacity
</div>
),
cell: ({ row }) => (
<div className="font-medium">{row.getValue("capacity")}</div>
),
enableSorting: false,
},
{
accessorKey: "temperature",
header: () => (
<div className="flex items-center">
<Thermometer className="mr-2 h-4 w-4" />
Temp.
</div>
),
cell: ({ row }) => {
const temp = row.getValue("temperature") as number
const getTemperatureColor = (temp: number) => {
if (temp >= 60) return "destructive"
if (temp >= 45) return "secondary"
return "default"
}
return (
<Badge variant={getTemperatureColor(temp)}>
{temp}°C
</Badge>
)
},
enableSorting: false,
},
{
accessorKey: "status",
header: () => (
<div className="flex items-center">
<Activity className="mr-2 h-4 w-4" />
Status
</div>
),
cell: ({ row }) => {
const status = row.getValue("status") as string
return (
<Badge
variant={status === "PASSED" ? "default" : "destructive"}
className={status === "PASSED" ? "bg-green-500 hover:bg-green-600 text-white" : ""}
>
{status}
</Badge>
)
},
enableSorting: false,
},
{
accessorKey: "deviceType",
header: () => (
<div className="flex items-center">
<Tags className="mr-2 h-4 w-4" />
Type
</div>
),
cell: ({ row }) => (
<Badge variant="outline" className="uppercase">
{row.getValue("deviceType")}
</Badge>
),
enableSorting: false,
},
{
accessorKey: "powerOnHours",
header: () => (
<div className="flex items-center">
<Clock className="mr-2 h-4 w-4" />
Power On Time
</div>
),
cell: ({ row }) => {
const hours = row.getValue("powerOnHours") as number | undefined
if (!hours && hours !== 0) {
return (
<div className="text-sm text-muted-foreground">
N/A
</div>
)
}
const days = Math.floor(hours / 24)
return (
<div className="text-sm">
<div>{hours.toLocaleString()} hours</div>
<div className="text-muted-foreground text-xs">{days} days</div>
</div>
)
},
enableSorting: false,
},
{
accessorKey: "serialNumber",
header: () => (
<div className="flex items-center">
<Binary className="mr-2 h-4 w-4" />
Serial Number
</div>
),
cell: ({ row }) => (
<div className="font-mono text-sm">{row.getValue("serialNumber")}</div>
),
enableSorting: false,
},
{
id: "actions",
enableHiding: false,
cell: () => null, // This will be overwritten by columnsWithSmartData
},
]
export default function DisksTab({ smartData }: { smartData?: Record<string, SmartData> }) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
// Convert SmartData to DiskInfo, if no data use empty array
const diskData = React.useMemo(() => {
return smartData ? convertSmartDataToDiskInfo(smartData) : []
}, [smartData])
// Create column definitions with SmartData
const columnsWithSmartData = React.useMemo(() => {
return columns.map(column => {
if (column.id === "actions") {
return {
...column,
cell: ({ row }: { row: any }) => {
const disk = row.original as DiskInfo
// Find the corresponding SmartData
const diskSmartData = smartData ? Object.values(smartData).find(
sd => sd.dn === disk.device || sd.mn === disk.model
) : undefined
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<SmartDialog disk={disk} smartData={diskSmartData} />
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(disk.device)}
>
Copy device path
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(disk.serialNumber)}
>
Copy serial number
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
}
}
return column
})
}, [smartData])
const table = useReactTable({
data: diskData,
columns: columnsWithSmartData,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
return (
<div>
<Card>
<CardHeader>
<CardTitle>Disk Information</CardTitle>
<CardDescription>Disk information and S.M.A.R.T. data</CardDescription>
</CardHeader>
<div className="px-6 pb-6">
<div className="w-full">
<div className="flex items-center py-4">
<Input
placeholder="Filter devices..."
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("device")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border grid">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{smartData ? "No disk data available." : "Loading disk data..."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredRowModel().rows.length} disk device(s)
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</div>
</Card>
</div>
)
}

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

@@ -51,7 +51,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
<th
ref={ref}
className={cn(
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
"h-12 px-4 text-start align-middle whitespace-nowrap font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
className
)}
{...props}
@@ -62,7 +62,7 @@ TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
<td ref={ref} className={cn("p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", className)} {...props} />
)
)
TableCell.displayName = "TableCell"

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 {
@@ -97,6 +100,8 @@ export interface SystemStats {
efs?: Record<string, ExtraFsStats>
/** GPU data */
g?: Record<string, GPUData>
/** SMART data */
sm?: Record<string, SmartData>
}
export interface GPUData {
@@ -205,3 +210,47 @@ interface AlertInfo {
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
}
export interface SmartData {
/** model family */
mf?: string
/** model name */
mn?: string
/** serial number */
sn?: string
/** firmware version */
fv?: string
/** capacity */
c?: number
/** smart status */
s?: string
/** disk name (like /dev/sda) */
dn?: string
/** disk type */
dt?: string
/** temperature */
t?: number
/** attributes */
a?: SmartAttribute[]
}
export interface SmartAttribute {
/** id */
id?: number
/** name */
n: string
/** value */
v: number
/** worst */
w?: number
/** threshold */
t?: number
/** raw value */
rv?: number
/** raw string */
rs?: string
/** flags */
f?: string
/** when failed */
wf?: string
}

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
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
}
@@ -232,14 +317,14 @@ fi
if is_alpine; then
if ! id -u beszel >/dev/null 2>&1; then
echo "Creating a dedicated user for the Beszel Agent service..."
adduser -D -H -s /sbin/nologin beszel
adduser -S -D -H -s /sbin/nologin beszel
fi
# Add the user to the docker group to allow access to the Docker socket
addgroup beszel docker
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
usermod -aG docker beszel
@@ -298,9 +383,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 +447,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 +537,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 +571,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 +595,6 @@ StateDirectory=beszel-agent
KeyringMode=private
LockPersonality=yes
NoNewPrivileges=yes
PrivateTmp=yes
ProtectClock=yes
ProtectHome=read-only
ProtectHostname=yes
@@ -488,6 +604,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 +616,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 +670,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