Repository: henrygd/beszel Branch: main Commit: adbfe7cfb7df Files: 357 Total size: 3.4 MB Directory structure: gitextract_xj546sdv/ ├── .dockerignore ├── .gitattributes ├── .github/ │ ├── CODEOWNERS │ ├── DISCUSSION_TEMPLATE/ │ │ ├── ideas.yml │ │ └── support.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── funding.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── docker-images.yml │ ├── inactivity-actions.yml │ ├── release.yml │ └── vulncheck.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── SECURITY.md ├── agent/ │ ├── agent.go │ ├── agent_cache.go │ ├── agent_cache_test.go │ ├── agent_test_helpers.go │ ├── battery/ │ │ ├── battery.go │ │ └── battery_freebsd.go │ ├── client.go │ ├── client_test.go │ ├── connection_manager.go │ ├── connection_manager_test.go │ ├── cpu.go │ ├── data_dir.go │ ├── data_dir_test.go │ ├── deltatracker/ │ │ ├── deltatracker.go │ │ └── deltatracker_test.go │ ├── disk.go │ ├── disk_test.go │ ├── docker.go │ ├── docker_test.go │ ├── emmc_common.go │ ├── emmc_common_test.go │ ├── emmc_linux.go │ ├── emmc_linux_test.go │ ├── emmc_stub.go │ ├── fingerprint.go │ ├── fingerprint_test.go │ ├── gpu.go │ ├── gpu_amd_linux.go │ ├── gpu_amd_linux_test.go │ ├── gpu_amd_unsupported.go │ ├── gpu_darwin.go │ ├── gpu_darwin_test.go │ ├── gpu_darwin_unsupported.go │ ├── gpu_intel.go │ ├── gpu_nvml.go │ ├── gpu_nvml_linux.go │ ├── gpu_nvml_unsupported.go │ ├── gpu_nvml_windows.go │ ├── gpu_nvtop.go │ ├── gpu_test.go │ ├── handlers.go │ ├── handlers_test.go │ ├── health/ │ │ ├── health.go │ │ └── health_test.go │ ├── lhm/ │ │ ├── beszel_lhm.cs │ │ └── beszel_lhm.csproj │ ├── mdraid_linux.go │ ├── mdraid_linux_test.go │ ├── mdraid_stub.go │ ├── network.go │ ├── network_test.go │ ├── response.go │ ├── sensors.go │ ├── sensors_default.go │ ├── sensors_test.go │ ├── sensors_windows.go │ ├── server.go │ ├── server_test.go │ ├── smart.go │ ├── smart_nonwindows.go │ ├── smart_test.go │ ├── smart_windows.go │ ├── system.go │ ├── systemd.go │ ├── systemd_nonlinux.go │ ├── systemd_nonlinux_test.go │ ├── systemd_test.go │ ├── test-data/ │ │ ├── amdgpu.ids │ │ ├── container.json │ │ ├── container2.json │ │ ├── nvtop.json │ │ ├── smart/ │ │ │ ├── nvme0.json │ │ │ ├── scan.json │ │ │ ├── scsi.json │ │ │ └── sda.json │ │ └── system_info.json │ ├── tools/ │ │ └── fetchsmartctl/ │ │ └── main.go │ ├── update.go │ ├── utils/ │ │ ├── utils.go │ │ └── utils_test.go │ └── zfs/ │ ├── zfs_freebsd.go │ ├── zfs_linux.go │ └── zfs_unsupported.go ├── beszel.go ├── go.mod ├── go.sum ├── i18n.yml ├── internal/ │ ├── alerts/ │ │ ├── alerts.go │ │ ├── alerts_api.go │ │ ├── alerts_battery_test.go │ │ ├── alerts_cache.go │ │ ├── alerts_cache_test.go │ │ ├── alerts_disk_test.go │ │ ├── alerts_history.go │ │ ├── alerts_quiet_hours_test.go │ │ ├── alerts_smart.go │ │ ├── alerts_smart_test.go │ │ ├── alerts_status.go │ │ ├── alerts_status_test.go │ │ ├── alerts_system.go │ │ ├── alerts_system_test.go │ │ ├── alerts_test.go │ │ └── alerts_test_helpers.go │ ├── cmd/ │ │ ├── agent/ │ │ │ ├── agent.go │ │ │ └── agent_test.go │ │ └── hub/ │ │ └── hub.go │ ├── common/ │ │ ├── common-ssh.go │ │ └── common-ws.go │ ├── dockerfile_agent │ ├── dockerfile_agent_alpine │ ├── dockerfile_agent_intel │ ├── dockerfile_agent_nvidia │ ├── dockerfile_hub │ ├── entities/ │ │ ├── container/ │ │ │ └── container.go │ │ ├── smart/ │ │ │ ├── smart.go │ │ │ └── smart_test.go │ │ ├── system/ │ │ │ └── system.go │ │ └── systemd/ │ │ ├── systemd.go │ │ └── systemd_test.go │ ├── ghupdate/ │ │ ├── extract.go │ │ ├── ghupdate.go │ │ ├── ghupdate_test.go │ │ ├── release.go │ │ ├── selinux.go │ │ └── selinux_test.go │ ├── hub/ │ │ ├── agent_connect.go │ │ ├── agent_connect_test.go │ │ ├── config/ │ │ │ ├── config.go │ │ │ └── config_test.go │ │ ├── expirymap/ │ │ │ ├── expirymap.go │ │ │ └── expirymap_test.go │ │ ├── heartbeat/ │ │ │ ├── heartbeat.go │ │ │ └── heartbeat_test.go │ │ ├── hub.go │ │ ├── hub_test.go │ │ ├── hub_test_helpers.go │ │ ├── server_development.go │ │ ├── server_production.go │ │ ├── systems/ │ │ │ ├── system.go │ │ │ ├── system_manager.go │ │ │ ├── system_realtime.go │ │ │ ├── system_smart.go │ │ │ ├── system_systemd_test.go │ │ │ ├── system_test.go │ │ │ ├── systems_production.go │ │ │ ├── systems_test.go │ │ │ └── systems_test_helpers.go │ │ ├── transport/ │ │ │ ├── ssh.go │ │ │ ├── transport.go │ │ │ └── websocket.go │ │ ├── update.go │ │ └── ws/ │ │ ├── handlers.go │ │ ├── request_manager.go │ │ ├── request_manager_test.go │ │ ├── ws.go │ │ ├── ws_test.go │ │ └── ws_test_helpers.go │ ├── migrations/ │ │ ├── 0_collections_snapshot_0_19_0_dev_1.go │ │ └── initial-settings.go │ ├── records/ │ │ ├── records.go │ │ ├── records_test.go │ │ └── records_test_helpers.go │ ├── site/ │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── biome.json │ │ ├── components.json │ │ ├── embed.go │ │ ├── index.html │ │ ├── lingui.config.ts │ │ ├── package.json │ │ ├── public/ │ │ │ └── static/ │ │ │ └── manifest.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── active-alerts.tsx │ │ │ │ ├── add-system.tsx │ │ │ │ ├── alerts/ │ │ │ │ │ ├── alert-button.tsx │ │ │ │ │ └── alerts-sheet.tsx │ │ │ │ ├── alerts-history-columns.tsx │ │ │ │ ├── charts/ │ │ │ │ │ ├── area-chart.tsx │ │ │ │ │ ├── chart-time-select.tsx │ │ │ │ │ ├── container-chart.tsx │ │ │ │ │ ├── disk-chart.tsx │ │ │ │ │ ├── gpu-power-chart.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── line-chart.tsx │ │ │ │ │ ├── load-average-chart.tsx │ │ │ │ │ ├── mem-chart.tsx │ │ │ │ │ ├── swap-chart.tsx │ │ │ │ │ └── temperature-chart.tsx │ │ │ │ ├── command-palette.tsx │ │ │ │ ├── containers-table/ │ │ │ │ │ ├── containers-table-columns.tsx │ │ │ │ │ └── containers-table.tsx │ │ │ │ ├── copy-to-clipboard.tsx │ │ │ │ ├── footer-repo-link.tsx │ │ │ │ ├── install-dropdowns.tsx │ │ │ │ ├── lang-toggle.tsx │ │ │ │ ├── login/ │ │ │ │ │ ├── auth-form.tsx │ │ │ │ │ ├── forgot-pass-form.tsx │ │ │ │ │ ├── login.tsx │ │ │ │ │ └── otp-forms.tsx │ │ │ │ ├── logo.tsx │ │ │ │ ├── mode-toggle.tsx │ │ │ │ ├── navbar.tsx │ │ │ │ ├── router.tsx │ │ │ │ ├── routes/ │ │ │ │ │ ├── containers.tsx │ │ │ │ │ ├── home.tsx │ │ │ │ │ ├── settings/ │ │ │ │ │ │ ├── alerts-history-data-table.tsx │ │ │ │ │ │ ├── config-yaml.tsx │ │ │ │ │ │ ├── general.tsx │ │ │ │ │ │ ├── heartbeat.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── notifications.tsx │ │ │ │ │ │ ├── quiet-hours.tsx │ │ │ │ │ │ ├── sidebar-nav.tsx │ │ │ │ │ │ └── tokens-fingerprints.tsx │ │ │ │ │ ├── smart.tsx │ │ │ │ │ ├── system/ │ │ │ │ │ │ ├── cpu-sheet.tsx │ │ │ │ │ │ ├── info-bar.tsx │ │ │ │ │ │ ├── network-sheet.tsx │ │ │ │ │ │ └── smart-table.tsx │ │ │ │ │ └── system.tsx │ │ │ │ ├── spinner.tsx │ │ │ │ ├── systemd-table/ │ │ │ │ │ ├── systemd-table-columns.tsx │ │ │ │ │ └── systemd-table.tsx │ │ │ │ ├── systems-table/ │ │ │ │ │ ├── systems-table-columns.tsx │ │ │ │ │ └── systems-table.tsx │ │ │ │ ├── theme-provider.tsx │ │ │ │ └── ui/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── alert.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── input-copy.tsx │ │ │ │ ├── input-tags.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── otp.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── slider.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toaster.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ └── use-toast.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ ├── alerts.ts │ │ │ │ ├── api.ts │ │ │ │ ├── enums.ts │ │ │ │ ├── i18n.ts │ │ │ │ ├── languages.ts │ │ │ │ ├── shiki.ts │ │ │ │ ├── stores.ts │ │ │ │ ├── systemsManager.ts │ │ │ │ ├── time.ts │ │ │ │ ├── use-intersection-observer.ts │ │ │ │ └── utils.ts │ │ │ ├── locales/ │ │ │ │ ├── ar/ │ │ │ │ │ └── ar.po │ │ │ │ ├── bg/ │ │ │ │ │ └── bg.po │ │ │ │ ├── cs/ │ │ │ │ │ └── cs.po │ │ │ │ ├── da/ │ │ │ │ │ └── da.po │ │ │ │ ├── de/ │ │ │ │ │ └── de.po │ │ │ │ ├── en/ │ │ │ │ │ └── en.po │ │ │ │ ├── es/ │ │ │ │ │ └── es.po │ │ │ │ ├── fa/ │ │ │ │ │ └── fa.po │ │ │ │ ├── fr/ │ │ │ │ │ └── fr.po │ │ │ │ ├── he/ │ │ │ │ │ └── he.po │ │ │ │ ├── hr/ │ │ │ │ │ └── hr.po │ │ │ │ ├── hu/ │ │ │ │ │ └── hu.po │ │ │ │ ├── id/ │ │ │ │ │ └── id.po │ │ │ │ ├── it/ │ │ │ │ │ └── it.po │ │ │ │ ├── ja/ │ │ │ │ │ └── ja.po │ │ │ │ ├── ko/ │ │ │ │ │ └── ko.po │ │ │ │ ├── nl/ │ │ │ │ │ └── nl.po │ │ │ │ ├── no/ │ │ │ │ │ └── no.po │ │ │ │ ├── pl/ │ │ │ │ │ └── pl.po │ │ │ │ ├── pt/ │ │ │ │ │ └── pt.po │ │ │ │ ├── ru/ │ │ │ │ │ └── ru.po │ │ │ │ ├── sl/ │ │ │ │ │ └── sl.po │ │ │ │ ├── sr/ │ │ │ │ │ └── sr.po │ │ │ │ ├── sv/ │ │ │ │ │ └── sv.po │ │ │ │ ├── tr/ │ │ │ │ │ └── tr.po │ │ │ │ ├── uk/ │ │ │ │ │ └── uk.po │ │ │ │ ├── vi/ │ │ │ │ │ └── vi.po │ │ │ │ ├── zh/ │ │ │ │ │ └── zh.po │ │ │ │ ├── zh-CN/ │ │ │ │ │ └── zh-CN.po │ │ │ │ └── zh-HK/ │ │ │ │ └── zh-HK.po │ │ │ ├── main.tsx │ │ │ ├── types.d.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── tests/ │ │ ├── api.go │ │ └── hub.go │ └── users/ │ └── users.go ├── readme.md └── supplemental/ ├── CHANGELOG.md ├── debian/ │ ├── beszel-agent.service │ ├── config.sh │ ├── copyright │ ├── lintian-overrides │ ├── postinstall.sh │ ├── postrm.sh │ ├── prerm.sh │ └── templates ├── docker/ │ ├── agent/ │ │ └── docker-compose.yml │ ├── hub/ │ │ └── docker-compose.yml │ └── same-system/ │ └── docker-compose.yml ├── guides/ │ └── systemd.md ├── kubernetes/ │ └── beszel-hub/ │ └── charts/ │ ├── .helmignore │ ├── Chart.yaml │ ├── templates/ │ │ ├── NOTES.txt │ │ ├── _helpers.tpl │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── service.yaml │ │ ├── tests/ │ │ │ └── test-beszel-hub-endpoint.yaml │ │ └── volume-claim.yaml │ └── values.yaml ├── licenses/ │ ├── LibreHardwareMonitor/ │ │ └── LICENSE │ └── smartmontools/ │ └── LICENSE └── scripts/ ├── install-agent-brew.sh ├── install-agent.ps1 ├── install-agent.sh ├── install-hub.sh ├── upgrade-agent-wrapper.ps1 └── upgrade-agent.ps1 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ # Node.js dependencies node_modules internalsite/node_modules # Go build artifacts and binaries build dist *.exe beszel-agent beszel_data* pb_data data temp # Development and IDE files .vscode .idea* *.swc __debug_* # Git and version control .git .gitignore # Documentation and supplemental files *.md supplemental freebsd-port # Test files (exclude from production builds) *_test.go coverage # Docker files dockerfile_* # Temporary files *.tmp *.bak *.log # OS specific files .DS_Store Thumbs.db # .NET build artifacts agent/lhm/obj agent/lhm/bin ================================================ FILE: .gitattributes ================================================ *.tsx linguist-language=Go ================================================ FILE: .github/CODEOWNERS ================================================ # Everything needs to be reviewed by Hank * @henrygd ================================================ FILE: .github/DISCUSSION_TEMPLATE/ideas.yml ================================================ body: - type: dropdown id: component attributes: label: Component description: Which part of Beszel is this about? options: - Hub - Agent - Hub & Agent default: 0 validations: required: true - type: textarea attributes: label: Description description: Please describe in detail what you want to share. validations: required: true ================================================ FILE: .github/DISCUSSION_TEMPLATE/support.yml ================================================ body: - type: checkboxes id: terms attributes: label: Welcome! description: | Thank you for reaching out to the Beszel community for support! To help us assist you better, please make sure to review the following points before submitting your request: Please note: - For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel). **- Please do not submit support reqeusts that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.** options: - label: I have read the [Documentation](https://beszel.dev/guide/getting-started) required: true - label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there. required: true - label: I have searched open and closed issues and discussions and my problem was not mentioned before. required: true - label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases). required: true - type: dropdown id: component attributes: label: Component description: Which part of Beszel is this about? options: - Hub - Agent - Hub & Agent default: 0 validations: required: true - type: textarea id: description attributes: label: Problem Description description: | How to write a good bug report? - Respect the issue template as much as possible. - The title should be short and descriptive. - Explain the conditions which led you to report this issue: the context. - The context should lead to something, a problem that you’re facing. - Remain clear and concise. - Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown) validations: required: true - type: input id: system attributes: label: OS / Architecture placeholder: linux/amd64 (agent), freebsd/arm64 (hub) validations: required: true # - type: input # id: version # attributes: # label: Beszel version # placeholder: 0.9.1 # validations: # required: true - type: dropdown id: install-method attributes: label: Installation method options: - Docker - Binary - Nix - Unraid - Coolify - Other (please describe above) validations: required: true - type: textarea id: config attributes: label: Configuration description: Please provide any relevant service configuration render: yaml - type: textarea id: hub-logs attributes: label: Hub Logs description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). render: json - type: textarea id: agent-logs attributes: label: Agent Logs description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 🐛 Bug report description: Use this template to report a bug or issue. title: '[Bug]: ' labels: ['bug'] body: - type: checkboxes attributes: label: Welcome! description: | The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions/new?category=support)** instead Please note: - For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel). - To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml). - Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed. **- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.** options: - label: I have read the [Documentation](https://beszel.dev/guide/getting-started) required: true - label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there. required: true - label: I have searched open and closed issues and my problem was not mentioned before. required: true - label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases). required: true - type: dropdown id: component attributes: label: Component description: Which part of Beszel is this about? options: - Hub - Agent - Hub & Agent default: 0 validations: required: true - type: textarea id: description attributes: label: Problem Description description: | How to write a good bug report? - Respect the issue template as much as possible. - The title should be short and descriptive. - Explain the conditions which led you to report this issue: the context. - The context should lead to something, a problem that you’re facing. - Remain clear and concise. - Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown) validations: required: true - type: textarea id: expected-behavior attributes: label: Expected Behavior description: | In a perfect world, what should have happened? **Important:** Be specific. Vague descriptions like "it should work" are not helpful. placeholder: When I got to the coffee pot, it should have been full. validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to Reproduce description: | Provide detailed, numbered steps that someone else can follow to reproduce the issue. **Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed. Include specific actions, URLs, button clicks, and any relevant data or configuration. placeholder: | 1. Go to the coffee pot. 2. Make more coffee. 3. Pour it into a cup. 4. Observe that the cup is empty instead of full. validations: required: true - type: input id: system attributes: label: OS / Architecture placeholder: linux/amd64 (agent), freebsd/arm64 (hub) validations: required: true - type: input id: version attributes: label: Beszel version placeholder: 0.9.1 validations: required: true - type: dropdown id: install-method attributes: label: Installation method options: - Docker - Binary - Nix - Unraid - Coolify - Other (please describe above) validations: required: true - type: textarea id: config attributes: label: Configuration description: Please provide any relevant service configuration render: yaml - type: textarea id: hub-logs attributes: label: Hub Logs description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON). render: json - type: textarea id: agent-logs attributes: label: Agent Logs description: Please provide any logs from the agent, if relevant. Use `LOG_LEVEL=debug` for more info. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: 🗣️ Translations url: https://crowdin.com/project/beszel about: Please report translation issues and request new translations here. - name: 💬 Support and questions url: https://github.com/henrygd/beszel/discussions about: Ask and answer questions here. - name: ℹ️ View the Common Issues page url: https://beszel.dev/guide/common-issues about: Find information about commonly encountered problems. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 🚀 Feature request description: Request a new feature or change. title: "[Feature]: " labels: ["enhancement"] body: - type: checkboxes attributes: label: Welcome! description: | The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions)** instead Please note: - For **Bug reports**, use the [Bug Form](https://github.com/henrygd/beszel/issues/new?template=bug_report.yml). - Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/beszel). - Create one issue per feature request. This helps us keep track of requests and prioritize them accordingly. options: - label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist. required: true - label: This is a feature request, not a bug report or support question. required: true - type: dropdown id: component attributes: label: Component description: Which part of Beszel is this about? options: - Hub - Agent - Hub & Agent default: 0 validations: required: true - type: textarea id: description attributes: label: Description description: | Describe the solution or feature you'd like. Explain what problem this solves or what value it adds. **Important:** Be specific and detailed. Vague requests like "make it better" will be closed. placeholder: | Example: - What is the feature? - What problem does it solve? - How should it work? validations: required: true - type: textarea id: motivation attributes: label: Motivation / Use Case description: Why do you want this feature? What problem does it solve? validations: required: true ================================================ FILE: .github/funding.yml ================================================ buy_me_a_coffee: henrygd ================================================ FILE: .github/pull_request_template.md ================================================ ## 📃 Description A short description of the pull request changes should go here and the sections below should list in detail all changes. You can remove the sections you don't need. ## 📖 Documentation Add a link to the PR for [documentation](https://github.com/henrygd/beszel-docs) changes. ## 🪵 Changelog ### ➕ Added - one - two ### ✏️ Changed - one - two ### 🔧 Fixed - one - two ### 🗑️ Removed - one - two ## 📷 Screenshots If this PR has any UI/UX changes it's strongly suggested you add screenshots here. ================================================ FILE: .github/workflows/docker-images.yml ================================================ name: Make docker images on: push: tags: - "v*" jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: include: # henrygd/beszel - image: henrygd/beszel dockerfile: ./internal/dockerfile_hub registry: docker.io username_secret: DOCKERHUB_USERNAME password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # henrygd/beszel-agent:alpine - image: henrygd/beszel-agent dockerfile: ./internal/dockerfile_agent_alpine registry: docker.io username_secret: DOCKERHUB_USERNAME password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=alpine type=semver,pattern={{version}}-alpine type=semver,pattern={{major}}.{{minor}}-alpine type=semver,pattern={{major}}-alpine # henrygd/beszel-agent-nvidia - image: henrygd/beszel-agent-nvidia dockerfile: ./internal/dockerfile_agent_nvidia platforms: linux/amd64 registry: docker.io username_secret: DOCKERHUB_USERNAME password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # henrygd/beszel-agent-intel - image: henrygd/beszel-agent-intel dockerfile: ./internal/dockerfile_agent_intel platforms: linux/amd64 registry: docker.io username_secret: DOCKERHUB_USERNAME password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # ghcr.io/henrygd/beszel - image: ghcr.io/${{ github.repository }}/beszel dockerfile: ./internal/dockerfile_hub registry: ghcr.io username: ${{ github.actor }} password_secret: GITHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # ghcr.io/henrygd/beszel-agent - image: ghcr.io/${{ github.repository }}/beszel-agent dockerfile: ./internal/dockerfile_agent registry: ghcr.io username: ${{ github.actor }} password_secret: GITHUB_TOKEN tags: | type=raw,value=edge type=raw,value=latest type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # ghcr.io/henrygd/beszel-agent-nvidia - image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia dockerfile: ./internal/dockerfile_agent_nvidia platforms: linux/amd64 registry: ghcr.io username: ${{ github.actor }} password_secret: GITHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # ghcr.io/henrygd/beszel-agent-intel - image: ghcr.io/${{ github.repository }}/beszel-agent-intel dockerfile: ./internal/dockerfile_agent_intel platforms: linux/amd64 registry: ghcr.io username: ${{ github.actor }} password_secret: GITHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} # ghcr.io/henrygd/beszel-agent:alpine - image: ghcr.io/${{ github.repository }}/beszel-agent dockerfile: ./internal/dockerfile_agent_alpine registry: ghcr.io username: ${{ github.actor }} password_secret: GITHUB_TOKEN tags: | type=raw,value=alpine type=semver,pattern={{version}}-alpine type=semver,pattern={{major}}.{{minor}}-alpine type=semver,pattern={{major}}-alpine # henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag) - image: henrygd/beszel-agent dockerfile: ./internal/dockerfile_agent registry: docker.io username_secret: DOCKERHUB_USERNAME password_secret: DOCKERHUB_TOKEN tags: | type=raw,value=edge type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }} permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@v4 - name: Set up bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --no-save --cwd ./internal/site - name: Build site run: bun run --cwd ./internal/site build - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Docker metadata id: metadata uses: docker/metadata-action@v5 with: images: ${{ matrix.image }} tags: ${{ matrix.tags }} # https://github.com/docker/login-action - name: Login to Docker Hub env: password_secret_exists: ${{ secrets[matrix.password_secret] != '' && 'true' || 'false' }} if: github.event_name != 'pull_request' && env.password_secret_exists == 'true' uses: docker/login-action@v3 with: username: ${{ matrix.username || secrets[matrix.username_secret] }} password: ${{ secrets[matrix.password_secret] }} registry: ${{ matrix.registry }} # Build and push Docker image with Buildx (don't push on PR) # https://github.com/docker/build-push-action - name: Build and push Docker image uses: docker/build-push-action@v5 with: context: ./ file: ${{ matrix.dockerfile }} platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }} push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} ================================================ FILE: .github/workflows/inactivity-actions.yml ================================================ name: 'Issue and PR Maintenance' on: schedule: - cron: '0 0 * * *' # runs at midnight UTC workflow_dispatch: permissions: actions: write issues: write pull-requests: write jobs: lock-inactive: name: Lock Inactive Issues runs-on: ubuntu-24.04 steps: - uses: klaasnicolaas/action-inactivity-lock@v1.1.3 id: lock with: days-inactive-issues: 14 lock-reason-issues: "" # Action can not skip PRs, set it to 100 years to cover it. days-inactive-prs: 36524 lock-reason-prs: "" close-stale: name: Close Stale Issues runs-on: ubuntu-24.04 steps: - name: Close Stale Issues uses: actions/stale@v10 with: repo-token: ${{ secrets.GITHUB_TOKEN }} # Messaging stale-issue-message: > 👋 This issue has been automatically marked as stale due to inactivity. If this issue is still relevant, please comment to keep it open. Without activity, it will be closed in 7 days. close-issue-message: > 🔒 This issue has been automatically closed due to prolonged inactivity. Feel free to open a new issue if you have further questions or concerns. # Timing days-before-issue-stale: 14 days-before-issue-close: 7 # Action can not skip PRs, set it to 100 years to cover it. days-before-pr-stale: 36524 # Max issues to process before early exit. Next run resumes from cache. GH API limit: 5000. operations-per-run: 1500 # Labels stale-issue-label: 'stale' remove-stale-when-updated: true any-of-labels: 'awaiting-requester' exempt-issue-labels: 'enhancement' # Exemptions exempt-assignees: true exempt-milestones: true ================================================ FILE: .github/workflows/release.yml ================================================ name: Make release and binaries on: push: tags: - "v*" permissions: contents: write jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up bun uses: oven-sh/setup-bun@v2 - name: Install dependencies run: bun install --no-save --cwd ./internal/site - name: Build site run: bun run --cwd ./internal/site build - name: Set up Go uses: actions/setup-go@v5 with: go-version: "^1.22.1" - name: Set up .NET uses: actions/setup-dotnet@v4 with: dotnet-version: "9.0.x" - name: Build .NET LHM executable for Windows sensors run: | dotnet build -c Release ./agent/lhm/beszel_lhm.csproj shell: bash - name: GoReleaser beszel uses: goreleaser/goreleaser-action@v6 with: workdir: ./ distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }} WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} IS_FORK: ${{ github.repository_owner != 'henrygd' }} ================================================ FILE: .github/workflows/vulncheck.yml ================================================ # https://github.com/minio/minio/blob/master/.github/workflows/vulncheck.yml name: VulnCheck on: pull_request: branches: - main push: branches: - main permissions: contents: read # to fetch code (actions/checkout) jobs: vulncheck: name: VulnCheck runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version: 1.26.x # cached: false - name: Get official govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest shell: bash - name: Run govulncheck run: govulncheck -show verbose ./... shell: bash ================================================ FILE: .gitignore ================================================ .idea.md pb_data data temp .vscode beszel-agent beszel_data beszel_data* dist *.exe internal/cmd/hub/hub internal/cmd/agent/agent agent.test node_modules build *timestamp* .swc internal/site/src/locales/**/*.ts *.bak __debug_* agent/lhm/obj agent/lhm/bin dockerfile_agent_dev ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: beszel before: hooks: - go mod tidy - go generate -run fetchsmartctl ./agent builds: - id: beszel binary: beszel main: internal/cmd/hub/hub.go env: - CGO_ENABLED=0 goos: - linux - darwin - windows - freebsd goarch: - amd64 - arm64 - arm ignore: - goos: windows goarch: arm64 - goos: windows goarch: arm - goos: freebsd goarch: arm64 - goos: freebsd goarch: arm - id: beszel-agent binary: beszel-agent main: internal/cmd/agent/agent.go env: - CGO_ENABLED=0 goos: - linux - darwin - freebsd - openbsd - windows goarch: - amd64 - arm64 - arm - mips64 - riscv64 - mipsle - mips - ppc64le gomips: - hardfloat - softfloat ignore: - goos: freebsd goarch: arm - goos: openbsd goarch: arm - goos: linux goarch: mips64 gomips: softfloat - goos: linux goarch: mipsle gomips: hardfloat - goos: linux goarch: mips gomips: hardfloat - goos: windows goarch: arm - goos: darwin goarch: riscv64 - goos: windows goarch: riscv64 - id: beszel-agent-linux-amd64-glibc binary: beszel-agent main: internal/cmd/agent/agent.go env: - CGO_ENABLED=0 flags: - -tags=glibc goos: - linux goarch: - amd64 archives: - id: beszel-agent formats: [tar.gz] ids: - beszel-agent name_template: >- {{ .Binary }}_ {{- .Os }}_ {{- .Arch }} format_overrides: - goos: windows formats: [zip] - id: beszel-agent-linux-amd64-glibc formats: [tar.gz] ids: - beszel-agent-linux-amd64-glibc name_template: >- {{ .Binary }}_ {{- .Os }}_ {{- .Arch }}_glibc - id: beszel formats: [tar.gz] ids: - beszel name_template: >- {{ .Binary }}_ {{- .Os }}_ {{- .Arch }} format_overrides: - goos: windows formats: [zip] nfpms: - id: beszel-agent package_name: beszel-agent description: |- Agent for Beszel 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. maintainer: henrygd section: net ids: - beszel-agent formats: - deb contents: - src: ./supplemental/debian/beszel-agent.service dst: lib/systemd/system/beszel-agent.service packager: deb - src: ./supplemental/debian/copyright dst: usr/share/doc/beszel-agent/copyright packager: deb - src: ./supplemental/debian/lintian-overrides dst: usr/share/lintian/overrides/beszel-agent packager: deb scripts: postinstall: ./supplemental/debian/postinstall.sh preremove: ./supplemental/debian/prerm.sh postremove: ./supplemental/debian/postrm.sh deb: predepends: - adduser - debconf scripts: templates: ./supplemental/debian/templates 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 skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' # # 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 skip_upload: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' 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 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: '{{ if eq (tolower .Env.IS_FORK) "true" }}true{{ else }}auto{{ end }}' 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 }} token: "{{ .Env.WINGET_TOKEN }}" # pull_request: # enabled: true # draft: false # base: # owner: microsoft # name: winget-pkgs # branch: master release: draft: true changelog: disable: true sort: asc filters: exclude: - "^docs:" - "^test:" ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 henrygd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ # Default OS/ARCH values OS ?= $(shell go env GOOS) ARCH ?= $(shell go env GOARCH) # Skip building the web UI if true SKIP_WEB ?= false # Controls NVML/glibc agent build tag behavior: # - auto (default): enable on linux/amd64 glibc hosts # - true: always enable # - false: always disable NVML ?= auto # Detect glibc host for local linux/amd64 builds. HOST_GLIBC := $(shell \ if [ "$(OS)" = "linux" ] && [ "$(ARCH)" = "amd64" ]; then \ for p in /lib64/ld-linux-x86-64.so.2 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib/ld-linux-x86-64.so.2; do \ [ -e "$$p" ] && { echo true; exit 0; }; \ done; \ if command -v ldd >/dev/null 2>&1; then \ if ldd --version 2>&1 | tr '[:upper:]' '[:lower:]' | awk '/gnu libc|glibc/{found=1} END{exit !found}'; then \ echo true; \ else \ echo false; \ fi; \ else \ echo false; \ fi; \ else \ echo false; \ fi) # Enable glibc build tag for NVML on supported Linux builds. AGENT_GO_TAGS := ifeq ($(NVML),true) AGENT_GO_TAGS := -tags glibc else ifeq ($(NVML),auto) ifeq ($(HOST_GLIBC),true) AGENT_GO_TAGS := -tags glibc endif endif # Set executable extension based on target OS EXE_EXT := $(if $(filter windows,$(OS)),.exe,) .PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional .DEFAULT_GOAL := build clean: go clean rm -rf ./build lint: golangci-lint run test: go test -tags=testing ./... tidy: go mod tidy build-web-ui: @if command -v bun >/dev/null 2>&1; then \ bun install --cwd ./internal/site && \ bun run --cwd ./internal/site build; \ else \ npm install --prefix ./internal/site && \ npm run --prefix ./internal/site build; \ fi # Conditional .NET build - only for Windows build-dotnet-conditional: @if [ "$(OS)" = "windows" ]; then \ echo "Building .NET executable for Windows..."; \ if command -v dotnet >/dev/null 2>&1; then \ rm -rf ./agent/lhm/bin; \ dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \ else \ echo "Error: dotnet not found. Install .NET SDK to build Windows agent."; \ exit 1; \ fi; \ fi # Download smartctl.exe at build time for Windows (skips if already present) fetch-smartctl-conditional: @if [ "$(OS)" = "windows" ]; then \ go generate -run fetchsmartctl ./agent; \ fi # Update build-agent to include conditional .NET build build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional GOOS=$(OS) GOARCH=$(ARCH) go build $(AGENT_GO_TAGS) -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui) GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub build-hub-dev: tidy mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html GOOS=$(OS) GOARCH=$(ARCH) go build -tags development -o ./build/beszel-dev_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/hub build: build-agent build-hub generate-locales: @if [ ! -f ./internal/site/src/locales/en/en.ts ]; then \ echo "Generating locales..."; \ command -v bun >/dev/null 2>&1 && cd ./internal/site && bun install && bun run sync || cd ./internal/site && npm install && npm run sync; \ fi dev-server: generate-locales cd ./internal/site @if command -v bun >/dev/null 2>&1; then \ cd ./internal/site && bun run dev --host 0.0.0.0; \ else \ cd ./internal/site && npm run dev --host 0.0.0.0; \ fi dev-hub: export ENV=dev dev-hub: mkdir -p ./internal/site/dist && touch ./internal/site/dist/index.html @if command -v entr >/dev/null 2>&1; then \ find ./internal -type f -name '*.go' | entr -r -s "cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090"; \ else \ cd ./internal/cmd/hub && go run -tags development . serve --http 0.0.0.0:8090; \ fi dev-agent: @if command -v entr >/dev/null 2>&1; then \ find ./internal/cmd/agent/*.go ./agent/*.go | entr -r go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \ else \ go run $(AGENT_GO_TAGS) github.com/henrygd/beszel/internal/cmd/agent; \ fi build-dotnet: @if command -v dotnet >/dev/null 2>&1; then \ rm -rf ./agent/lhm/bin; \ dotnet build -c Release ./agent/lhm/beszel_lhm.csproj; \ else \ echo "dotnet not found"; \ fi # KEY="..." make -j dev dev: dev-server dev-hub dev-agent ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Reporting a Vulnerability If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new). If it's low severity (use best judgement) you may open an issue instead of an advisory. ================================================ FILE: agent/agent.go ================================================ // Package agent implements the Beszel monitoring agent that collects and serves system metrics. // // The agent runs on monitored systems and communicates collected data // to the Beszel hub for centralized monitoring and alerting. package agent import ( "log/slog" "strings" "sync" "time" "github.com/gliderlabs/ssh" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" gossh "golang.org/x/crypto/ssh" ) 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 diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks) lastDiskUsageUpdate time.Time // Last time disk usage was collected netInterfaces map[string]struct{} // Stores all valid network interfaces netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers dockerManager *dockerManager // Manages Docker API requests sensorConfig *SensorConfig // Sensors config systemInfo system.Info // Host system info (dynamic) systemDetails system.Details // Host system details (static, once-per-connection) gpuManager *GPUManager // Manages GPU data cache *systemDataCache // Cache for system stats based on cache time connectionManager *ConnectionManager // Channel to signal connection events handlerRegistry *HandlerRegistry // Registry for routing incoming messages server *ssh.Server // SSH server dataDir string // Directory for persisting data keys []gossh.PublicKey // SSH public keys smartManager *SmartManager // Manages SMART data systemdManager *systemdManager // Manages systemd services } // NewAgent creates a new agent with the given data directory for persisting data. // If the data directory is not set, it will attempt to find the optimal directory. func NewAgent(dataDir ...string) (agent *Agent, err error) { agent = &Agent{ fsStats: make(map[string]*system.FsStats), cache: NewSystemDataCache(), } // Initialize disk I/O previous counters storage agent.diskPrev = make(map[uint16]map[string]prevDisk) // Initialize per-cache-time network tracking structures agent.netIoStats = make(map[uint16]system.NetIoStats) agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64]) agent.dataDir, err = GetDataDir(dataDir...) if err != nil { slog.Warn("Data directory not found") } else { slog.Info("Data directory", "path", agent.dataDir) } agent.memCalc, _ = utils.GetEnv("MEM_CALC") agent.sensorConfig = agent.newSensorConfig() // Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks if diskUsageCache, exists := utils.GetEnv("DISK_USAGE_CACHE"); exists { if duration, err := time.ParseDuration(diskUsageCache); err == nil { agent.diskUsageCacheDuration = duration slog.Info("DISK_USAGE_CACHE", "duration", duration) } else { slog.Warn("Invalid DISK_USAGE_CACHE", "err", err) } } // Set up slog with a log level determined by the LOG_LEVEL env var if logLevelStr, exists := utils.GetEnv("LOG_LEVEL"); exists { switch strings.ToLower(logLevelStr) { case "debug": agent.debug = true slog.SetLogLoggerLevel(slog.LevelDebug) case "warn": slog.SetLogLoggerLevel(slog.LevelWarn) case "error": slog.SetLogLoggerLevel(slog.LevelError) } } slog.Debug(beszel.Version) // initialize docker manager agent.dockerManager = newDockerManager() // initialize system info agent.refreshSystemDetails() // SMART_INTERVAL env var to update smart data at this interval if smartIntervalEnv, exists := utils.GetEnv("SMART_INTERVAL"); exists { if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 { agent.systemDetails.SmartInterval = duration slog.Info("SMART_INTERVAL", "duration", duration) } else { slog.Warn("Invalid SMART_INTERVAL", "err", err) } } // initialize connection manager agent.connectionManager = newConnectionManager(agent) // initialize handler registry agent.handlerRegistry = NewHandlerRegistry() // initialize disk info agent.initializeDiskInfo() // initialize net io stats agent.initializeNetIoStats() agent.systemdManager, err = newSystemdManager() if err != nil { slog.Debug("Systemd", "err", err) } agent.smartManager, err = NewSmartManager() if err != nil { slog.Debug("SMART", "err", err) } // initialize GPU manager agent.gpuManager, err = NewGPUManager() if err != nil { slog.Debug("GPU", "err", err) } // if debugging, print stats if agent.debug { slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true})) } return agent, nil } func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData { a.Lock() defer a.Unlock() cacheTimeMs := options.CacheTimeMs data, isCached := a.cache.Get(cacheTimeMs) if isCached { slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs) return data } *data = system.CombinedData{ Stats: a.getSystemStats(cacheTimeMs), Info: a.systemInfo, } // Include static system details only when requested if options.IncludeDetails { data.Details = &a.systemDetails } // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) if a.dockerManager != nil { if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil { data.Containers = containerStats slog.Debug("Containers", "data", data.Containers) } else { slog.Debug("Containers", "err", err) } } // skip updating systemd services if cache time is not the default 60sec interval if a.systemdManager != nil && cacheTimeMs == 60_000 { totalCount := uint16(a.systemdManager.getServiceStatsCount()) if totalCount > 0 { numFailed := a.systemdManager.getFailedServiceCount() data.Info.Services = []uint16{totalCount, numFailed} } if a.systemdManager.hasFreshStats { data.SystemdServices = a.systemdManager.getServiceStats(nil, false) } } data.Stats.ExtraFs = make(map[string]*system.FsStats) data.Info.ExtraFsPct = make(map[string]float64) for name, stats := range a.fsStats { if !stats.Root && stats.DiskTotal > 0 { // Use custom name if available, otherwise use device name key := name if stats.Name != "" { key = stats.Name } data.Stats.ExtraFs[key] = stats // Add percentages to Info struct for dashboard if stats.DiskTotal > 0 { pct := utils.TwoDecimals((stats.DiskUsed / stats.DiskTotal) * 100) data.Info.ExtraFsPct[key] = pct } } } slog.Debug("Extra FS", "data", data.Stats.ExtraFs) a.cache.Set(data, cacheTimeMs) return data } // Start initializes and starts the agent with optional WebSocket connection func (a *Agent) Start(serverOptions ServerOptions) error { a.keys = serverOptions.Keys return a.connectionManager.Start(serverOptions) } func (a *Agent) getFingerprint() string { return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel) } ================================================ FILE: agent/agent_cache.go ================================================ package agent import ( "sync" "time" "github.com/henrygd/beszel/internal/entities/system" ) type systemDataCache struct { sync.RWMutex cache map[uint16]*cacheNode } type cacheNode struct { data *system.CombinedData lastUpdate time.Time } // NewSystemDataCache creates a cache keyed by the polling interval in milliseconds. func NewSystemDataCache() *systemDataCache { return &systemDataCache{ cache: make(map[uint16]*cacheNode), } } // Get returns cached combined data when the entry is still considered fresh. func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) { c.RLock() defer c.RUnlock() node, ok := c.cache[cacheTimeMs] if !ok { return &system.CombinedData{}, false } // allowedSkew := time.Second // isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew // allow a 50% skew of the cache time isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond return node.data, isFresh } // Set stores the latest combined data snapshot for the given interval. func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) { c.Lock() defer c.Unlock() node, ok := c.cache[cacheTimeMs] if !ok { node = &cacheNode{} c.cache[cacheTimeMs] = node } node.data = data node.lastUpdate = time.Now() } ================================================ FILE: agent/agent_cache_test.go ================================================ //go:build testing package agent import ( "testing" "testing/synctest" "time" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func createTestCacheData() *system.CombinedData { return &system.CombinedData{ Stats: system.Stats{ Cpu: 50.5, Mem: 8192, DiskTotal: 100000, }, Info: system.Info{ AgentVersion: "0.12.0", }, Containers: []*container.Stats{ { Name: "test-container", Cpu: 25.0, }, }, } } func TestNewSystemDataCache(t *testing.T) { cache := NewSystemDataCache() require.NotNil(t, cache) assert.NotNil(t, cache.cache) assert.Empty(t, cache.cache) } func TestCacheGetSet(t *testing.T) { cache := NewSystemDataCache() data := createTestCacheData() // Test setting data cache.Set(data, 1000) // 1 second cache // Test getting fresh data retrieved, isCached := cache.Get(1000) assert.True(t, isCached) assert.Equal(t, data, retrieved) // Test getting non-existent cache key _, isCached = cache.Get(2000) assert.False(t, isCached) } func TestCacheFreshness(t *testing.T) { cache := NewSystemDataCache() data := createTestCacheData() testCases := []struct { name string cacheTimeMs uint16 sleepMs time.Duration expectFresh bool }{ { name: "fresh data - well within cache time", cacheTimeMs: 1000, // 1 second sleepMs: 100, // 100ms expectFresh: true, }, { name: "fresh data - at 50% of cache time boundary", cacheTimeMs: 1000, // 1 second, 50% = 500ms sleepMs: 499, // just under 500ms expectFresh: true, }, { name: "stale data - exactly at 50% cache time", cacheTimeMs: 1000, // 1 second, 50% = 500ms sleepMs: 500, // exactly 500ms expectFresh: false, }, { name: "stale data - well beyond cache time", cacheTimeMs: 1000, // 1 second sleepMs: 800, // 800ms expectFresh: false, }, { name: "short cache time", cacheTimeMs: 200, // 200ms, 50% = 100ms sleepMs: 150, // 150ms > 100ms expectFresh: false, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { synctest.Test(t, func(t *testing.T) { // Set data cache.Set(data, tc.cacheTimeMs) // Wait for the specified duration if tc.sleepMs > 0 { time.Sleep(tc.sleepMs * time.Millisecond) } // Check freshness _, isCached := cache.Get(tc.cacheTimeMs) assert.Equal(t, tc.expectFresh, isCached) }) }) } } func TestCacheMultipleIntervals(t *testing.T) { synctest.Test(t, func(t *testing.T) { cache := NewSystemDataCache() data1 := createTestCacheData() data2 := &system.CombinedData{ Stats: system.Stats{ Cpu: 75.0, Mem: 16384, }, Info: system.Info{ AgentVersion: "0.12.0", }, Containers: []*container.Stats{}, } // Set data for different intervals cache.Set(data1, 500) // 500ms cache cache.Set(data2, 1000) // 1000ms cache // Both should be fresh immediately retrieved1, isCached1 := cache.Get(500) assert.True(t, isCached1) assert.Equal(t, data1, retrieved1) retrieved2, isCached2 := cache.Get(1000) assert.True(t, isCached2) assert.Equal(t, data2, retrieved2) // Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold) time.Sleep(300 * time.Millisecond) _, isCached1 = cache.Get(500) assert.False(t, isCached1) _, isCached2 = cache.Get(1000) assert.True(t, isCached2) // Wait another 300ms (total 600ms) - now 1000ms cache should also be stale time.Sleep(300 * time.Millisecond) _, isCached2 = cache.Get(1000) assert.False(t, isCached2) }) } func TestCacheOverwrite(t *testing.T) { cache := NewSystemDataCache() data1 := createTestCacheData() data2 := &system.CombinedData{ Stats: system.Stats{ Cpu: 90.0, Mem: 32768, }, Info: system.Info{ AgentVersion: "0.12.0", }, Containers: []*container.Stats{}, } // Set initial data cache.Set(data1, 1000) retrieved, isCached := cache.Get(1000) assert.True(t, isCached) assert.Equal(t, data1, retrieved) // Overwrite with new data cache.Set(data2, 1000) retrieved, isCached = cache.Get(1000) assert.True(t, isCached) assert.Equal(t, data2, retrieved) assert.NotEqual(t, data1, retrieved) } func TestCacheMiss(t *testing.T) { synctest.Test(t, func(t *testing.T) { cache := NewSystemDataCache() // Test getting from empty cache _, isCached := cache.Get(1000) assert.False(t, isCached) // Set data for one interval data := createTestCacheData() cache.Set(data, 1000) // Test getting different interval _, isCached = cache.Get(2000) assert.False(t, isCached) // Test getting after data has expired time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms) _, isCached = cache.Get(1000) assert.False(t, isCached) }) } func TestCacheZeroInterval(t *testing.T) { cache := NewSystemDataCache() data := createTestCacheData() // Set with zero interval - should allow immediate cache cache.Set(data, 0) // With 0 interval, 50% is 0, so it should never be considered fresh // (time.Since(lastUpdate) >= 0, which is not < 0) _, isCached := cache.Get(0) assert.False(t, isCached) } func TestCacheLargeInterval(t *testing.T) { synctest.Test(t, func(t *testing.T) { cache := NewSystemDataCache() data := createTestCacheData() // Test with maximum uint16 value cache.Set(data, 65535) // ~65 seconds // Should be fresh immediately _, isCached := cache.Get(65535) assert.True(t, isCached) // Should still be fresh after a short time time.Sleep(100 * time.Millisecond) _, isCached = cache.Get(65535) assert.True(t, isCached) }) } ================================================ FILE: agent/agent_test_helpers.go ================================================ //go:build testing package agent // TESTING ONLY: GetConnectionManager is a helper function to get the connection manager for testing. func (a *Agent) GetConnectionManager() *ConnectionManager { return a.connectionManager } ================================================ FILE: agent/battery/battery.go ================================================ //go:build !freebsd // Package battery provides functions to check if the system has a battery and to get the battery stats. package battery import ( "errors" "log/slog" "math" "github.com/distatus/battery" ) var ( systemHasBattery = false haveCheckedBattery = false ) // HasReadableBattery checks if the system has a battery and returns true if it does. func HasReadableBattery() bool { if haveCheckedBattery { return systemHasBattery } haveCheckedBattery = true batteries, err := battery.GetAll() for _, bat := range batteries { if bat != nil && (bat.Full > 0 || bat.Design > 0) { systemHasBattery = true break } } if !systemHasBattery { slog.Debug("No battery found", "err", err) } return systemHasBattery } // GetBatteryStats returns the current battery percent and charge state // percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries) func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) { if !HasReadableBattery() { return batteryPercent, batteryState, errors.ErrUnsupported } batteries, err := battery.GetAll() // we'll handle errors later by skipping batteries with errors, rather // than skipping everything because of the presence of some errors. if len(batteries) == 0 { return batteryPercent, batteryState, errors.New("no batteries") } totalCapacity := float64(0) totalCharge := float64(0) errs, partialErrs := err.(battery.Errors) batteryState = math.MaxUint8 for i, bat := range batteries { if partialErrs && errs[i] != nil { // if there were some errors, like missing data, skip it continue } if bat == nil || bat.Full == 0 { // skip batteries with no capacity. Charge is unlikely to ever be zero, but // we can't guarantee that, so don't skip based on charge. continue } totalCapacity += bat.Full totalCharge += min(bat.Current, bat.Full) if bat.State.Raw >= 0 { batteryState = uint8(bat.State.Raw) } } if totalCapacity == 0 || batteryState == math.MaxUint8 { // for macs there's sometimes a ghost battery with 0 capacity // https://github.com/distatus/battery/issues/34 // Instead of skipping over those batteries, we'll check for total 0 capacity // and return an error. This also prevents a divide by zero. return batteryPercent, batteryState, errors.New("no battery capacity") } batteryPercent = uint8(totalCharge / totalCapacity * 100) return batteryPercent, batteryState, nil } ================================================ FILE: agent/battery/battery_freebsd.go ================================================ //go:build freebsd package battery import "errors" func HasReadableBattery() bool { return false } func GetBatteryStats() (uint8, uint8, error) { return 0, 0, errors.ErrUnsupported } ================================================ FILE: agent/client.go ================================================ package agent import ( "crypto/tls" "errors" "fmt" "log/slog" "net" "net/http" "net/url" "os" "path" "strings" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/common" "github.com/fxamacker/cbor/v2" "github.com/lxzan/gws" "golang.org/x/crypto/ssh" ) const ( wsDeadline = 70 * time.Second ) // WebSocketClient manages the WebSocket connection between the agent and hub. // It handles authentication, message routing, and connection lifecycle management. type WebSocketClient struct { gws.BuiltinEventHandler options *gws.ClientOption // WebSocket client configuration options agent *Agent // Reference to the parent agent Conn *gws.Conn // Active WebSocket connection hubURL *url.URL // Parsed hub URL for connection token string // Authentication token for hub registration fingerprint string // System fingerprint for identification hubRequest *common.HubRequest[cbor.RawMessage] // Reusable request structure for message parsing lastConnectAttempt time.Time // Timestamp of last connection attempt hubVerified bool // Whether the hub has been cryptographically verified } // newWebSocketClient creates a new WebSocket client for the given agent. // It reads configuration from environment variables and validates the hub URL. func newWebSocketClient(agent *Agent) (client *WebSocketClient, err error) { hubURLStr, exists := utils.GetEnv("HUB_URL") if !exists { return nil, errors.New("HUB_URL environment variable not set") } client = &WebSocketClient{} client.hubURL, err = url.Parse(hubURLStr) if err != nil { return nil, errors.New("invalid hub URL") } // get registration token client.token, err = getToken() if err != nil { return nil, err } client.agent = agent client.hubRequest = &common.HubRequest[cbor.RawMessage]{} client.fingerprint = agent.getFingerprint() return client, nil } // getToken returns the token for the WebSocket client. // It first checks the TOKEN environment variable, then the TOKEN_FILE environment variable. // If neither is set, it returns an error. func getToken() (string, error) { // get token from env var token, _ := utils.GetEnv("TOKEN") if token != "" { return token, nil } // get token from file tokenFile, _ := utils.GetEnv("TOKEN_FILE") if tokenFile == "" { return "", errors.New("must set TOKEN or TOKEN_FILE") } tokenBytes, err := os.ReadFile(tokenFile) if err != nil { return "", err } return strings.TrimSpace(string(tokenBytes)), nil } // getOptions returns the WebSocket client options, creating them if necessary. // It configures the connection URL, TLS settings, and authentication headers. func (client *WebSocketClient) getOptions() *gws.ClientOption { if client.options != nil { return client.options } // update the hub url to use websocket scheme and api path if client.hubURL.Scheme == "https" { client.hubURL.Scheme = "wss" } else { client.hubURL.Scheme = "ws" } client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect") client.options = &gws.ClientOption{ Addr: client.hubURL.String(), TlsConfig: &tls.Config{InsecureSkipVerify: true}, RequestHeader: http.Header{ "User-Agent": []string{getUserAgent()}, "X-Token": []string{client.token}, "X-Beszel": []string{beszel.Version}, }, } return client.options } // Connect establishes a WebSocket connection to the hub. // It closes any existing connection before attempting to reconnect. func (client *WebSocketClient) Connect() (err error) { client.lastConnectAttempt = time.Now() // make sure previous connection is closed client.Close() client.Conn, _, err = gws.NewClient(client, client.getOptions()) if err != nil { return err } go client.Conn.ReadLoop() return nil } // OnOpen handles WebSocket connection establishment. // It sets a deadline for the connection to prevent hanging. func (client *WebSocketClient) OnOpen(conn *gws.Conn) { conn.SetDeadline(time.Now().Add(wsDeadline)) } // OnClose handles WebSocket connection closure. // It logs the closure reason and notifies the connection manager. func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) { if err != nil { slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: ")) } client.agent.connectionManager.eventChan <- WebSocketDisconnect } // OnMessage handles incoming WebSocket messages from the hub. // It decodes CBOR messages and routes them to appropriate handlers. func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) { defer message.Close() conn.SetDeadline(time.Now().Add(wsDeadline)) if message.Opcode != gws.OpcodeBinary { return } var HubRequest common.HubRequest[cbor.RawMessage] err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest) if err != nil { slog.Error("Error parsing message", "err", err) return } if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil { slog.Error("Error handling message", "err", err) } } // OnPing handles WebSocket ping frames. // It responds with a pong and updates the connection deadline. func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) { conn.SetDeadline(time.Now().Add(wsDeadline)) conn.WritePong(message) } // handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint. func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) { var authRequest common.FingerprintRequest if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil { return err } if err := client.verifySignature(authRequest.Signature); err != nil { return err } client.hubVerified = true client.agent.connectionManager.eventChan <- WebSocketConnect response := &common.FingerprintResponse{ Fingerprint: client.fingerprint, } if authRequest.NeedSysInfo { response.Name, _ = utils.GetEnv("SYSTEM_NAME") response.Hostname = client.agent.systemDetails.Hostname serverAddr := client.agent.connectionManager.serverOptions.Addr _, response.Port, _ = net.SplitHostPort(serverAddr) } return client.sendResponse(response, requestID) } // verifySignature verifies the signature of the token using the public keys. func (client *WebSocketClient) verifySignature(signature []byte) (err error) { for _, pubKey := range client.agent.keys { sig := ssh.Signature{ Format: pubKey.Type(), Blob: signature, } if err = pubKey.Verify([]byte(client.token), &sig); err == nil { return nil } } return errors.New("invalid signature - check KEY value") } // Close closes the WebSocket connection gracefully. // This method is safe to call multiple times. func (client *WebSocketClient) Close() { if client.Conn != nil { _ = client.Conn.WriteClose(1000, nil) } } // handleHubRequest routes the request to the appropriate handler using the handler registry. func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error { ctx := &HandlerContext{ Client: client, Agent: client.agent, Request: msg, RequestID: requestID, HubVerified: client.hubVerified, SendResponse: client.sendResponse, } return client.agent.handlerRegistry.Handle(ctx) } // sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub. func (client *WebSocketClient) sendMessage(data any) error { bytes, err := cbor.Marshal(data) if err != nil { return err } err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes) if err != nil { // If writing fails (e.g., broken pipe due to network issues), // close the connection to trigger reconnection logic (#1263) client.Close() } return err } // sendResponse sends a response with optional request ID. // For ID-based requests, we must populate legacy typed fields for backward // compatibility with older hubs (<= 0.17) that don't read the generic Data field. func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error { if requestID != nil { response := newAgentResponse(data, requestID) return client.sendMessage(response) } // Legacy format - send data directly return client.sendMessage(data) } // getUserAgent returns one of two User-Agent strings based on current time. // This is used to avoid being blocked by Cloudflare or other anti-bot measures. func getUserAgent() string { const ( uaBase = "Mozilla/5.0 (%s) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" uaWindows = "Windows NT 11.0; Win64; x64" uaMac = "Macintosh; Intel Mac OS X 14_0_0" ) if time.Now().UnixNano()%2 == 0 { return fmt.Sprintf(uaBase, uaWindows) } return fmt.Sprintf(uaBase, uaMac) } ================================================ FILE: agent/client_test.go ================================================ //go:build testing package agent import ( "crypto/ed25519" "net/url" "os" "strings" "testing" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/common" "github.com/fxamacker/cbor/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) // TestNewWebSocketClient tests WebSocket client creation func TestNewWebSocketClient(t *testing.T) { agent := createTestAgent(t) testCases := []struct { name string hubURL string token string expectError bool errorMsg string }{ { name: "valid configuration", hubURL: "http://localhost:8080", token: "test-token-123", expectError: false, }, { name: "valid https URL", hubURL: "https://hub.example.com", token: "secure-token", expectError: false, }, { name: "missing hub URL", hubURL: "", token: "test-token", expectError: true, errorMsg: "HUB_URL environment variable not set", }, { name: "invalid URL", hubURL: "ht\ttp://invalid", token: "test-token", expectError: true, errorMsg: "invalid hub URL", }, { name: "missing token", hubURL: "http://localhost:8080", token: "", expectError: true, errorMsg: "must set TOKEN or TOKEN_FILE", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Set up environment if tc.hubURL != "" { os.Setenv("BESZEL_AGENT_HUB_URL", tc.hubURL) } else { os.Unsetenv("BESZEL_AGENT_HUB_URL") } if tc.token != "" { os.Setenv("BESZEL_AGENT_TOKEN", tc.token) } else { os.Unsetenv("BESZEL_AGENT_TOKEN") } defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) if tc.expectError { assert.Error(t, err) if err != nil && tc.errorMsg != "" { assert.Contains(t, err.Error(), tc.errorMsg) } assert.Nil(t, client) } else { require.NoError(t, err) assert.NotNil(t, client) assert.Equal(t, agent, client.agent) assert.Equal(t, tc.token, client.token) assert.Equal(t, tc.hubURL, client.hubURL.String()) assert.NotEmpty(t, client.fingerprint) assert.NotNil(t, client.hubRequest) } }) } } // TestWebSocketClient_GetOptions tests WebSocket client options configuration func TestWebSocketClient_GetOptions(t *testing.T) { agent := createTestAgent(t) testCases := []struct { name string inputURL string expectedScheme string expectedPath string }{ { name: "http to ws conversion", inputURL: "http://localhost:8080", expectedScheme: "ws", expectedPath: "/api/beszel/agent-connect", }, { name: "https to wss conversion", inputURL: "https://hub.example.com", expectedScheme: "wss", expectedPath: "/api/beszel/agent-connect", }, { name: "existing path preservation", inputURL: "http://localhost:8080/custom/path", expectedScheme: "ws", expectedPath: "/custom/path/api/beszel/agent-connect", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Set up environment os.Setenv("BESZEL_AGENT_HUB_URL", tc.inputURL) os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) require.NoError(t, err) options := client.getOptions() // Parse the WebSocket URL wsURL, err := url.Parse(options.Addr) require.NoError(t, err) assert.Equal(t, tc.expectedScheme, wsURL.Scheme) assert.Equal(t, tc.expectedPath, wsURL.Path) // Check headers assert.Equal(t, "test-token", options.RequestHeader.Get("X-Token")) assert.Equal(t, beszel.Version, options.RequestHeader.Get("X-Beszel")) assert.Contains(t, options.RequestHeader.Get("User-Agent"), "Mozilla/5.0") // Test options caching options2 := client.getOptions() assert.Same(t, options, options2, "Options should be cached") }) } } // TestWebSocketClient_VerifySignature tests signature verification func TestWebSocketClient_VerifySignature(t *testing.T) { agent := createTestAgent(t) // Generate test key pairs _, goodPrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) goodPubKey, err := ssh.NewPublicKey(goodPrivKey.Public().(ed25519.PublicKey)) require.NoError(t, err) _, badPrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey)) require.NoError(t, err) // Set up environment os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) require.NoError(t, err) testCases := []struct { name string keys []ssh.PublicKey token string signWith ed25519.PrivateKey expectError bool }{ { name: "valid signature with correct key", keys: []ssh.PublicKey{goodPubKey}, token: "test-token", signWith: goodPrivKey, expectError: false, }, { name: "invalid signature with wrong key", keys: []ssh.PublicKey{goodPubKey}, token: "test-token", signWith: badPrivKey, expectError: true, }, { name: "valid signature with multiple keys", keys: []ssh.PublicKey{badPubKey, goodPubKey}, token: "test-token", signWith: goodPrivKey, expectError: false, }, { name: "no valid keys", keys: []ssh.PublicKey{badPubKey}, token: "test-token", signWith: goodPrivKey, expectError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Set up agent with test keys agent.keys = tc.keys client.token = tc.token // Create signature signature := ed25519.Sign(tc.signWith, []byte(tc.token)) err := client.verifySignature(signature) if tc.expectError { assert.Error(t, err) assert.Contains(t, err.Error(), "invalid signature") } else { assert.NoError(t, err) } }) } } // TestWebSocketClient_HandleHubRequest tests hub request routing (basic verification logic) func TestWebSocketClient_HandleHubRequest(t *testing.T) { agent := createTestAgent(t) // Set up environment os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) require.NoError(t, err) testCases := []struct { name string action common.WebSocketAction hubVerified bool expectError bool errorMsg string }{ { name: "CheckFingerprint without verification", action: common.CheckFingerprint, hubVerified: false, expectError: false, // CheckFingerprint is allowed without verification }, { name: "GetData without verification", action: common.GetData, hubVerified: false, expectError: true, errorMsg: "hub not verified", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { client.hubVerified = tc.hubVerified // Create minimal request hubRequest := &common.HubRequest[cbor.RawMessage]{ Action: tc.action, Data: cbor.RawMessage{}, } err := client.handleHubRequest(hubRequest, nil) if tc.expectError { assert.Error(t, err) if tc.errorMsg != "" { assert.Contains(t, err.Error(), tc.errorMsg) } } else { // For CheckFingerprint, we expect a decode error since we're not providing valid data, // but it shouldn't be the "hub not verified" error if err != nil && tc.errorMsg != "" { assert.NotContains(t, err.Error(), tc.errorMsg) } } }) } } // TestWebSocketClient_GetUserAgent tests user agent generation func TestGetUserAgent(t *testing.T) { // Run multiple times to check both variants userAgents := make(map[string]bool) for range 20 { ua := getUserAgent() userAgents[ua] = true // Check that it's a valid Mozilla user agent assert.Contains(t, ua, "Mozilla/5.0") assert.Contains(t, ua, "AppleWebKit/537.36") assert.Contains(t, ua, "Chrome/124.0.0.0") assert.Contains(t, ua, "Safari/537.36") // Should contain either Windows or Mac isWindows := strings.Contains(ua, "Windows NT 11.0") isMac := strings.Contains(ua, "Macintosh; Intel Mac OS X 14_0_0") assert.True(t, isWindows || isMac, "User agent should contain either Windows or Mac identifier") } // With enough iterations, we should see both variants // though this might occasionally fail if len(userAgents) == 1 { t.Log("Note: Only one user agent variant was generated in this test run") } } // TestWebSocketClient_Close tests connection closing func TestWebSocketClient_Close(t *testing.T) { agent := createTestAgent(t) // Set up environment os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) require.NoError(t, err) // Test closing with nil connection (should not panic) assert.NotPanics(t, func() { client.Close() }) } // TestWebSocketClient_ConnectRateLimit tests connection rate limiting func TestWebSocketClient_ConnectRateLimit(t *testing.T) { agent := createTestAgent(t) // Set up environment os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() client, err := newWebSocketClient(agent) require.NoError(t, err) // Set recent connection attempt client.lastConnectAttempt = time.Now() // Test that connection fails quickly due to rate limiting // This won't actually connect but should fail fast err = client.Connect() assert.Error(t, err, "Connection should fail but not hang") } // TestGetToken tests the getToken function with various scenarios func TestGetToken(t *testing.T) { unsetEnvVars := func() { os.Unsetenv("BESZEL_AGENT_TOKEN") os.Unsetenv("TOKEN") os.Unsetenv("BESZEL_AGENT_TOKEN_FILE") os.Unsetenv("TOKEN_FILE") } t.Run("token from TOKEN environment variable", func(t *testing.T) { unsetEnvVars() // Set TOKEN env var expectedToken := "test-token-from-env" os.Setenv("TOKEN", expectedToken) defer os.Unsetenv("TOKEN") token, err := getToken() assert.NoError(t, err) assert.Equal(t, expectedToken, token) }) t.Run("token from BESZEL_AGENT_TOKEN environment variable", func(t *testing.T) { unsetEnvVars() // Set BESZEL_AGENT_TOKEN env var (should take precedence) expectedToken := "test-token-from-beszel-env" os.Setenv("BESZEL_AGENT_TOKEN", expectedToken) defer os.Unsetenv("BESZEL_AGENT_TOKEN") token, err := getToken() assert.NoError(t, err) assert.Equal(t, expectedToken, token) }) t.Run("token from TOKEN_FILE", func(t *testing.T) { unsetEnvVars() // Create a temporary token file expectedToken := "test-token-from-file" tokenFile, err := os.CreateTemp("", "token-test-*.txt") require.NoError(t, err) defer os.Remove(tokenFile.Name()) _, err = tokenFile.WriteString(expectedToken) require.NoError(t, err) tokenFile.Close() // Set TOKEN_FILE env var os.Setenv("TOKEN_FILE", tokenFile.Name()) defer os.Unsetenv("TOKEN_FILE") token, err := getToken() assert.NoError(t, err) assert.Equal(t, expectedToken, token) }) t.Run("token from BESZEL_AGENT_TOKEN_FILE", func(t *testing.T) { unsetEnvVars() // Create a temporary token file expectedToken := "test-token-from-beszel-file" tokenFile, err := os.CreateTemp("", "token-test-*.txt") require.NoError(t, err) defer os.Remove(tokenFile.Name()) _, err = tokenFile.WriteString(expectedToken) require.NoError(t, err) tokenFile.Close() // Set BESZEL_AGENT_TOKEN_FILE env var (should take precedence) os.Setenv("BESZEL_AGENT_TOKEN_FILE", tokenFile.Name()) defer os.Unsetenv("BESZEL_AGENT_TOKEN_FILE") token, err := getToken() assert.NoError(t, err) assert.Equal(t, expectedToken, token) }) t.Run("TOKEN takes precedence over TOKEN_FILE", func(t *testing.T) { unsetEnvVars() // Create a temporary token file fileToken := "token-from-file" tokenFile, err := os.CreateTemp("", "token-test-*.txt") require.NoError(t, err) defer os.Remove(tokenFile.Name()) _, err = tokenFile.WriteString(fileToken) require.NoError(t, err) tokenFile.Close() // Set both TOKEN and TOKEN_FILE envToken := "token-from-env" os.Setenv("TOKEN", envToken) os.Setenv("TOKEN_FILE", tokenFile.Name()) defer func() { os.Unsetenv("TOKEN") os.Unsetenv("TOKEN_FILE") }() token, err := getToken() assert.NoError(t, err) assert.Equal(t, envToken, token, "TOKEN should take precedence over TOKEN_FILE") }) t.Run("error when neither TOKEN nor TOKEN_FILE is set", func(t *testing.T) { unsetEnvVars() token, err := getToken() assert.Error(t, err) assert.Equal(t, "", token) assert.Contains(t, err.Error(), "must set TOKEN or TOKEN_FILE") }) t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) { unsetEnvVars() // Set TOKEN_FILE to a non-existent file os.Setenv("TOKEN_FILE", "/non/existent/file.txt") defer os.Unsetenv("TOKEN_FILE") token, err := getToken() assert.Error(t, err) assert.Equal(t, "", token) assert.Contains(t, err.Error(), "no such file or directory") }) t.Run("handles empty token file", func(t *testing.T) { unsetEnvVars() // Create an empty token file tokenFile, err := os.CreateTemp("", "token-test-*.txt") require.NoError(t, err) defer os.Remove(tokenFile.Name()) tokenFile.Close() // Set TOKEN_FILE env var os.Setenv("TOKEN_FILE", tokenFile.Name()) defer os.Unsetenv("TOKEN_FILE") token, err := getToken() assert.NoError(t, err) assert.Equal(t, "", token, "Empty file should return empty string") }) t.Run("strips whitespace from TOKEN_FILE", func(t *testing.T) { unsetEnvVars() tokenWithWhitespace := " test-token-with-whitespace \n\t" expectedToken := "test-token-with-whitespace" tokenFile, err := os.CreateTemp("", "token-test-*.txt") require.NoError(t, err) defer os.Remove(tokenFile.Name()) _, err = tokenFile.WriteString(tokenWithWhitespace) require.NoError(t, err) tokenFile.Close() os.Setenv("TOKEN_FILE", tokenFile.Name()) defer os.Unsetenv("TOKEN_FILE") token, err := getToken() assert.NoError(t, err) assert.Equal(t, expectedToken, token, "Whitespace should be stripped from token file content") }) } ================================================ FILE: agent/connection_manager.go ================================================ package agent import ( "context" "errors" "log/slog" "os/signal" "syscall" "time" "github.com/henrygd/beszel/agent/health" "github.com/henrygd/beszel/internal/entities/system" ) // ConnectionManager manages the connection state and events for the agent. // It handles both WebSocket and SSH connections, automatically switching between // them based on availability and managing reconnection attempts. type ConnectionManager struct { agent *Agent // Reference to the parent agent State ConnectionState // Current connection state eventChan chan ConnectionEvent // Channel for connection events wsClient *WebSocketClient // WebSocket client for hub communication serverOptions ServerOptions // Configuration for SSH server wsTicker *time.Ticker // Ticker for WebSocket connection attempts isConnecting bool // Prevents multiple simultaneous reconnection attempts ConnectionType system.ConnectionType } // ConnectionState represents the current connection state of the agent. type ConnectionState uint8 // ConnectionEvent represents connection-related events that can occur. type ConnectionEvent uint8 // Connection states const ( Disconnected ConnectionState = iota // No active connection WebSocketConnected // Connected via WebSocket SSHConnected // Connected via SSH ) // Connection events const ( WebSocketConnect ConnectionEvent = iota // WebSocket connection established WebSocketDisconnect // WebSocket connection lost SSHConnect // SSH connection established SSHDisconnect // SSH connection lost ) const wsTickerInterval = 10 * time.Second // newConnectionManager creates a new connection manager for the given agent. func newConnectionManager(agent *Agent) *ConnectionManager { cm := &ConnectionManager{ agent: agent, State: Disconnected, } return cm } // startWsTicker starts or resets the WebSocket connection attempt ticker. func (c *ConnectionManager) startWsTicker() { if c.wsTicker == nil { c.wsTicker = time.NewTicker(wsTickerInterval) } else { c.wsTicker.Reset(wsTickerInterval) } } // stopWsTicker stops the WebSocket connection attempt ticker. func (c *ConnectionManager) stopWsTicker() { if c.wsTicker != nil { c.wsTicker.Stop() } } // Start begins connection attempts and enters the main event loop. // It handles connection events, periodic health updates, and graceful shutdown. func (c *ConnectionManager) Start(serverOptions ServerOptions) error { if c.eventChan != nil { return errors.New("already started") } wsClient, err := newWebSocketClient(c.agent) if err != nil { slog.Warn("Error creating WebSocket client", "err", err) } c.wsClient = wsClient c.serverOptions = serverOptions c.eventChan = make(chan ConnectionEvent, 1) // signal handling for shutdown sigCtx, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stopSignals() c.startWsTicker() c.connect() // update health status immediately and every 90 seconds _ = health.Update() healthTicker := time.Tick(90 * time.Second) for { select { case connectionEvent := <-c.eventChan: c.handleEvent(connectionEvent) case <-c.wsTicker.C: _ = c.startWebSocketConnection() case <-healthTicker: _ = health.Update() case <-sigCtx.Done(): slog.Info("Shutting down", "cause", context.Cause(sigCtx)) _ = c.agent.StopServer() c.closeWebSocket() return health.CleanUp() } } } // handleEvent processes connection events and updates the connection state accordingly. func (c *ConnectionManager) handleEvent(event ConnectionEvent) { switch event { case WebSocketConnect: c.handleStateChange(WebSocketConnected) case SSHConnect: c.handleStateChange(SSHConnected) case WebSocketDisconnect: if c.State == WebSocketConnected { c.handleStateChange(Disconnected) } case SSHDisconnect: if c.State == SSHConnected { c.handleStateChange(Disconnected) } } } // handleStateChange updates the connection state and performs necessary actions // based on the new state, including stopping services and initiating reconnections. func (c *ConnectionManager) handleStateChange(newState ConnectionState) { if c.State == newState { return } c.State = newState switch newState { case WebSocketConnected: slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host) c.ConnectionType = system.ConnectionTypeWebSocket c.stopWsTicker() _ = c.agent.StopServer() c.isConnecting = false case SSHConnected: // stop new ws connection attempts slog.Info("SSH connection established") c.ConnectionType = system.ConnectionTypeSSH c.stopWsTicker() c.isConnecting = false case Disconnected: c.ConnectionType = system.ConnectionTypeNone if c.isConnecting { // Already handling reconnection, avoid duplicate attempts return } c.isConnecting = true slog.Warn("Disconnected from hub") // make sure old ws connection is closed c.closeWebSocket() // reconnect go c.connect() } } // connect handles the connection logic with proper delays and priority. // It attempts WebSocket connection first, falling back to SSH server if needed. func (c *ConnectionManager) connect() { c.isConnecting = true defer func() { c.isConnecting = false }() if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second { time.Sleep(5 * time.Second) } // Try WebSocket first, if it fails, start SSH server err := c.startWebSocketConnection() if err != nil && c.State == Disconnected { c.startSSHServer() c.startWsTicker() } } // startWebSocketConnection attempts to establish a WebSocket connection to the hub. func (c *ConnectionManager) startWebSocketConnection() error { if c.State != Disconnected { return errors.New("already connected") } if c.wsClient == nil { return errors.New("WebSocket client not initialized") } if time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second { return errors.New("already connecting") } err := c.wsClient.Connect() if err != nil { slog.Warn("WebSocket connection failed", "err", err) c.closeWebSocket() } return err } // startSSHServer starts the SSH server if the agent is currently disconnected. func (c *ConnectionManager) startSSHServer() { if c.State == Disconnected { go c.agent.StartServer(c.serverOptions) } } // closeWebSocket closes the WebSocket connection if it exists. func (c *ConnectionManager) closeWebSocket() { if c.wsClient != nil { c.wsClient.Close() } } ================================================ FILE: agent/connection_manager_test.go ================================================ //go:build testing package agent import ( "crypto/ed25519" "fmt" "net" "net/url" "os" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) func createTestAgent(t *testing.T) *Agent { dataDir := t.TempDir() agent, err := NewAgent(dataDir) require.NoError(t, err) return agent } func createTestServerOptions(t *testing.T) ServerOptions { // Generate test key pair _, privKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) sshPubKey, err := ssh.NewPublicKey(privKey.Public().(ed25519.PublicKey)) require.NoError(t, err) // Find available port listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) port := listener.Addr().(*net.TCPAddr).Port listener.Close() return ServerOptions{ Network: "tcp", Addr: fmt.Sprintf("127.0.0.1:%d", port), Keys: []ssh.PublicKey{sshPubKey}, } } // TestConnectionManager_NewConnectionManager tests connection manager creation func TestConnectionManager_NewConnectionManager(t *testing.T) { agent := createTestAgent(t) cm := newConnectionManager(agent) assert.NotNil(t, cm, "Connection manager should not be nil") assert.Equal(t, agent, cm.agent, "Agent reference should be set") assert.Equal(t, Disconnected, cm.State, "Initial state should be Disconnected") assert.Nil(t, cm.eventChan, "Event channel should be nil initially") assert.Nil(t, cm.wsClient, "WebSocket client should be nil initially") assert.Nil(t, cm.wsTicker, "WebSocket ticker should be nil initially") assert.False(t, cm.isConnecting, "isConnecting should be false initially") } // TestConnectionManager_StateTransitions tests basic state transitions func TestConnectionManager_StateTransitions(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager initialState := cm.State cm.wsClient = &WebSocketClient{ hubURL: &url.URL{ Host: "localhost:8080", }, } assert.NotNil(t, cm, "Connection manager should not be nil") assert.Equal(t, Disconnected, initialState, "Initial state should be Disconnected") // Test state transitions cm.handleStateChange(WebSocketConnected) assert.Equal(t, WebSocketConnected, cm.State, "State should change to WebSocketConnected") cm.handleStateChange(SSHConnected) assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected") cm.handleStateChange(Disconnected) assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected") // Test that same state doesn't trigger changes cm.State = WebSocketConnected cm.handleStateChange(WebSocketConnected) assert.Equal(t, WebSocketConnected, cm.State, "Same state should not trigger change") } // TestConnectionManager_EventHandling tests event handling logic func TestConnectionManager_EventHandling(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager cm.wsClient = &WebSocketClient{ hubURL: &url.URL{ Host: "localhost:8080", }, } testCases := []struct { name string initialState ConnectionState event ConnectionEvent expectedState ConnectionState }{ { name: "WebSocket connect from disconnected", initialState: Disconnected, event: WebSocketConnect, expectedState: WebSocketConnected, }, { name: "SSH connect from disconnected", initialState: Disconnected, event: SSHConnect, expectedState: SSHConnected, }, { name: "WebSocket disconnect from connected", initialState: WebSocketConnected, event: WebSocketDisconnect, expectedState: Disconnected, }, { name: "SSH disconnect from connected", initialState: SSHConnected, event: SSHDisconnect, expectedState: Disconnected, }, { name: "WebSocket disconnect from SSH connected (no change)", initialState: SSHConnected, event: WebSocketDisconnect, expectedState: SSHConnected, }, { name: "SSH disconnect from WebSocket connected (no change)", initialState: WebSocketConnected, event: SSHDisconnect, expectedState: WebSocketConnected, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { cm.State = tc.initialState cm.handleEvent(tc.event) assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event") }) } } // TestConnectionManager_TickerManagement tests WebSocket ticker management func TestConnectionManager_TickerManagement(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager // Test starting ticker cm.startWsTicker() assert.NotNil(t, cm.wsTicker, "Ticker should be created") // Test stopping ticker (should not panic) assert.NotPanics(t, func() { cm.stopWsTicker() }, "Stopping ticker should not panic") // Test stopping nil ticker (should not panic) cm.wsTicker = nil assert.NotPanics(t, func() { cm.stopWsTicker() }, "Stopping nil ticker should not panic") // Test restarting ticker cm.startWsTicker() assert.NotNil(t, cm.wsTicker, "Ticker should be recreated") // Test resetting existing ticker firstTicker := cm.wsTicker cm.startWsTicker() assert.Equal(t, firstTicker, cm.wsTicker, "Same ticker instance should be reused") cm.stopWsTicker() } // TestConnectionManager_WebSocketConnectionFlow tests WebSocket connection logic func TestConnectionManager_WebSocketConnectionFlow(t *testing.T) { if testing.Short() { t.Skip("Skipping WebSocket connection test in short mode") } agent := createTestAgent(t) cm := agent.connectionManager // Test WebSocket connection without proper environment err := cm.startWebSocketConnection() assert.Error(t, err, "WebSocket connection should fail without proper environment") assert.Equal(t, Disconnected, cm.State, "State should remain Disconnected after failed connection") // Test with invalid URL os.Setenv("BESZEL_AGENT_HUB_URL", "invalid-url") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() // Test with missing token os.Setenv("BESZEL_AGENT_HUB_URL", "http://localhost:8080") os.Unsetenv("BESZEL_AGENT_TOKEN") _, err2 := newWebSocketClient(agent) assert.Error(t, err2, "WebSocket client creation should fail without token") } // TestConnectionManager_ReconnectionLogic tests reconnection prevention logic func TestConnectionManager_ReconnectionLogic(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager cm.eventChan = make(chan ConnectionEvent, 1) // Test that isConnecting flag prevents duplicate reconnection attempts // Start from connected state, then simulate disconnect cm.State = WebSocketConnected cm.isConnecting = false // First disconnect should trigger reconnection logic cm.handleStateChange(Disconnected) assert.Equal(t, Disconnected, cm.State, "Should change to disconnected") assert.True(t, cm.isConnecting, "Should set isConnecting flag") } // TestConnectionManager_ConnectWithRateLimit tests connection rate limiting func TestConnectionManager_ConnectWithRateLimit(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager // Set up environment for WebSocket client creation os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() // Create WebSocket client wsClient, err := newWebSocketClient(agent) require.NoError(t, err) cm.wsClient = wsClient // Set recent connection attempt cm.wsClient.lastConnectAttempt = time.Now() // Test that connection is rate limited err = cm.startWebSocketConnection() assert.Error(t, err, "Should error due to rate limiting") assert.Contains(t, err.Error(), "already connecting", "Error should indicate rate limiting") // Test connection after rate limit expires cm.wsClient.lastConnectAttempt = time.Now().Add(-10 * time.Second) err = cm.startWebSocketConnection() // This will fail due to no actual server, but should not be rate limited assert.Error(t, err, "Connection should fail but not due to rate limiting") assert.NotContains(t, err.Error(), "already connecting", "Error should not indicate rate limiting") } // TestConnectionManager_StartWithInvalidConfig tests starting with invalid configuration func TestConnectionManager_StartWithInvalidConfig(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager serverOptions := createTestServerOptions(t) // Test starting when already started cm.eventChan = make(chan ConnectionEvent, 5) err := cm.Start(serverOptions) assert.Error(t, err, "Should error when starting already started connection manager") } // TestConnectionManager_CloseWebSocket tests WebSocket closing func TestConnectionManager_CloseWebSocket(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager // Test closing when no WebSocket client exists assert.NotPanics(t, func() { cm.closeWebSocket() }, "Should not panic when closing nil WebSocket client") // Set up environment and create WebSocket client os.Setenv("BESZEL_AGENT_HUB_URL", "ws://localhost:8080") os.Setenv("BESZEL_AGENT_TOKEN", "test-token") defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() wsClient, err := newWebSocketClient(agent) require.NoError(t, err) cm.wsClient = wsClient // Test closing when WebSocket client exists assert.NotPanics(t, func() { cm.closeWebSocket() }, "Should not panic when closing WebSocket client") } // TestConnectionManager_ConnectFlow tests the connect method func TestConnectionManager_ConnectFlow(t *testing.T) { agent := createTestAgent(t) cm := agent.connectionManager // Test connect without WebSocket client assert.NotPanics(t, func() { cm.connect() }, "Connect should not panic without WebSocket client") } ================================================ FILE: agent/cpu.go ================================================ package agent import ( "math" "runtime" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/cpu" ) var lastCpuTimes = make(map[uint16]cpu.TimesStat) var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat) // init initializes the CPU monitoring by storing the initial CPU times // for the default 60-second cache interval. func init() { if times, err := cpu.Times(false); err == nil && len(times) > 0 { lastCpuTimes[60000] = times[0] } if perCoreTimes, err := cpu.Times(true); err == nil && len(perCoreTimes) > 0 { lastPerCoreCpuTimes[60000] = perCoreTimes } } // CpuMetrics contains detailed CPU usage breakdown type CpuMetrics struct { Total float64 User float64 System float64 Iowait float64 Steal float64 Idle float64 } // getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements. // It returns percentages for total, user, system, iowait, and steal time. func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) { times, err := cpu.Times(false) if err != nil || len(times) == 0 { return CpuMetrics{}, err } // if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime if _, ok := lastCpuTimes[cacheTimeMs]; !ok { lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000] } t1 := lastCpuTimes[cacheTimeMs] t2 := times[0] t1All, _ := getAllBusy(t1) t2All, _ := getAllBusy(t2) totalDelta := t2All - t1All if totalDelta <= 0 { return CpuMetrics{}, nil } metrics := CpuMetrics{ Total: calculateBusy(t1, t2), User: clampPercent((t2.User - t1.User) / totalDelta * 100), System: clampPercent((t2.System - t1.System) / totalDelta * 100), Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100), Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100), Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100), } lastCpuTimes[cacheTimeMs] = times[0] return metrics, nil } // clampPercent ensures the percentage is between 0 and 100 func clampPercent(value float64) float64 { return math.Min(100, math.Max(0, value)) } // getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100). // It uses cached previous measurements for the provided cache interval. func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) { perCoreTimes, err := cpu.Times(true) if err != nil || len(perCoreTimes) == 0 { return nil, err } // Initialize cache if needed if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok { lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000] } lastTimes := lastPerCoreCpuTimes[cacheTimeMs] // Limit to the number of cores available in both samples length := min(len(lastTimes), len(perCoreTimes)) usage := make([]uint8, length) for i := 0; i < length; i++ { t1 := lastTimes[i] t2 := perCoreTimes[i] usage[i] = uint8(math.Round(calculateBusy(t1, t2))) } lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes return usage, nil } // calculateBusy calculates the CPU busy percentage between two time points. // It computes the ratio of busy time to total time elapsed between t1 and t2, // returning a percentage clamped between 0 and 100. func calculateBusy(t1, t2 cpu.TimesStat) float64 { t1All, t1Busy := getAllBusy(t1) t2All, t2Busy := getAllBusy(t2) if t2All <= t1All || t2Busy <= t1Busy { return 0 } return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100) } // getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics. // On Linux, it excludes guest and guest_nice time from the total to match kernel behavior. // Returns total CPU time and busy CPU time (total minus idle and I/O wait time). func getAllBusy(t cpu.TimesStat) (float64, float64) { tot := t.Total() if runtime.GOOS == "linux" { tot -= t.Guest // Linux 2.6.24+ tot -= t.GuestNice // Linux 3.2.0+ } busy := tot - t.Idle - t.Iowait return tot, busy } ================================================ FILE: agent/data_dir.go ================================================ package agent import ( "errors" "fmt" "os" "path/filepath" "runtime" "github.com/henrygd/beszel/agent/utils" ) // GetDataDir returns the path to the data directory for the agent and an error // if the directory is not valid. Attempts to find the optimal data directory if // no data directories are provided. func GetDataDir(dataDirs ...string) (string, error) { if len(dataDirs) > 0 { return testDataDirs(dataDirs) } dataDir, _ := utils.GetEnv("DATA_DIR") if dataDir != "" { dataDirs = append(dataDirs, dataDir) } if runtime.GOOS == "windows" { dataDirs = append(dataDirs, filepath.Join(os.Getenv("APPDATA"), "beszel-agent"), filepath.Join(os.Getenv("LOCALAPPDATA"), "beszel-agent"), ) } else { dataDirs = append(dataDirs, "/var/lib/beszel-agent") if homeDir, err := os.UserHomeDir(); err == nil { dataDirs = append(dataDirs, filepath.Join(homeDir, ".config", "beszel")) } } return testDataDirs(dataDirs) } func testDataDirs(paths []string) (string, error) { // first check if the directory exists and is writable for _, path := range paths { if valid, _ := isValidDataDir(path, false); valid { return path, nil } } // if the directory doesn't exist, try to create it for _, path := range paths { exists, _ := directoryExists(path) if exists { continue } if err := os.MkdirAll(path, 0755); err != nil { continue } // Verify the created directory is actually writable writable, _ := directoryIsWritable(path) if !writable { continue } return path, nil } return "", errors.New("data directory not found") } func isValidDataDir(path string, createIfNotExists bool) (bool, error) { exists, err := directoryExists(path) if err != nil { return false, err } if !exists { if !createIfNotExists { return false, nil } if err = os.MkdirAll(path, 0755); err != nil { return false, err } } // Always check if the directory is writable writable, err := directoryIsWritable(path) if err != nil { return false, err } return writable, nil } // directoryExists checks if a directory exists func directoryExists(path string) (bool, error) { // Check if directory exists stat, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } if !stat.IsDir() { return false, fmt.Errorf("%s is not a directory", path) } return true, nil } // directoryIsWritable tests if a directory is writable by creating and removing a temporary file func directoryIsWritable(path string) (bool, error) { testFile := filepath.Join(path, ".write-test") file, err := os.Create(testFile) if err != nil { return false, err } defer file.Close() defer os.Remove(testFile) return true, nil } ================================================ FILE: agent/data_dir_test.go ================================================ //go:build testing package agent import ( "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetDataDir(t *testing.T) { // Test with explicit dataDir parameter t.Run("explicit data dir", func(t *testing.T) { tempDir := t.TempDir() result, err := GetDataDir(tempDir) require.NoError(t, err) assert.Equal(t, tempDir, result) }) // Test with explicit non-existent dataDir that can be created t.Run("explicit data dir - create new", func(t *testing.T) { tempDir := t.TempDir() newDir := filepath.Join(tempDir, "new-data-dir") result, err := GetDataDir(newDir) require.NoError(t, err) assert.Equal(t, newDir, result) // Verify directory was created stat, err := os.Stat(newDir) require.NoError(t, err) assert.True(t, stat.IsDir()) }) // Test with DATA_DIR environment variable t.Run("DATA_DIR environment variable", func(t *testing.T) { tempDir := t.TempDir() // Set environment variable oldValue := os.Getenv("DATA_DIR") defer func() { if oldValue == "" { os.Unsetenv("BESZEL_AGENT_DATA_DIR") } else { os.Setenv("BESZEL_AGENT_DATA_DIR", oldValue) } }() os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir) result, err := GetDataDir() require.NoError(t, err) assert.Equal(t, tempDir, result) }) // Test with invalid explicit dataDir t.Run("invalid explicit data dir", func(t *testing.T) { invalidPath := "/invalid/path/that/cannot/be/created" _, err := GetDataDir(invalidPath) assert.Error(t, err) }) // Test fallback behavior (empty dataDir, no env var) t.Run("fallback to default directories", func(t *testing.T) { // Clear DATA_DIR environment variable oldValue := os.Getenv("DATA_DIR") defer func() { if oldValue == "" { os.Unsetenv("DATA_DIR") } else { os.Setenv("DATA_DIR", oldValue) } }() os.Unsetenv("DATA_DIR") // This will try platform-specific defaults, which may or may not work // We're mainly testing that it doesn't panic and returns some result result, err := GetDataDir() // We don't assert success/failure here since it depends on system permissions // Just verify we get a string result if no error if err == nil { assert.NotEmpty(t, result) } }) } func TestTestDataDirs(t *testing.T) { // Test with existing valid directory t.Run("existing valid directory", func(t *testing.T) { tempDir := t.TempDir() result, err := testDataDirs([]string{tempDir}) require.NoError(t, err) assert.Equal(t, tempDir, result) }) // Test with multiple directories, first one valid t.Run("multiple dirs - first valid", func(t *testing.T) { tempDir := t.TempDir() invalidDir := "/invalid/path" result, err := testDataDirs([]string{tempDir, invalidDir}) require.NoError(t, err) assert.Equal(t, tempDir, result) }) // Test with multiple directories, second one valid t.Run("multiple dirs - second valid", func(t *testing.T) { tempDir := t.TempDir() invalidDir := "/invalid/path" result, err := testDataDirs([]string{invalidDir, tempDir}) require.NoError(t, err) assert.Equal(t, tempDir, result) }) // Test with non-existing directory that can be created t.Run("create new directory", func(t *testing.T) { tempDir := t.TempDir() newDir := filepath.Join(tempDir, "new-dir") result, err := testDataDirs([]string{newDir}) require.NoError(t, err) assert.Equal(t, newDir, result) // Verify directory was created stat, err := os.Stat(newDir) require.NoError(t, err) assert.True(t, stat.IsDir()) }) // Test with no valid directories t.Run("no valid directories", func(t *testing.T) { invalidPaths := []string{"/invalid/path1", "/invalid/path2"} _, err := testDataDirs(invalidPaths) assert.Error(t, err) assert.Contains(t, err.Error(), "data directory not found") }) } func TestIsValidDataDir(t *testing.T) { // Test with existing directory t.Run("existing directory", func(t *testing.T) { tempDir := t.TempDir() valid, err := isValidDataDir(tempDir, false) require.NoError(t, err) assert.True(t, valid) }) // Test with non-existing directory, createIfNotExists=false t.Run("non-existing dir - no create", func(t *testing.T) { tempDir := t.TempDir() nonExistentDir := filepath.Join(tempDir, "does-not-exist") valid, err := isValidDataDir(nonExistentDir, false) require.NoError(t, err) assert.False(t, valid) }) // Test with non-existing directory, createIfNotExists=true t.Run("non-existing dir - create", func(t *testing.T) { tempDir := t.TempDir() newDir := filepath.Join(tempDir, "new-dir") valid, err := isValidDataDir(newDir, true) require.NoError(t, err) assert.True(t, valid) // Verify directory was created stat, err := os.Stat(newDir) require.NoError(t, err) assert.True(t, stat.IsDir()) }) // Test with file instead of directory t.Run("file instead of directory", func(t *testing.T) { tempDir := t.TempDir() tempFile := filepath.Join(tempDir, "testfile") err := os.WriteFile(tempFile, []byte("test"), 0644) require.NoError(t, err) valid, err := isValidDataDir(tempFile, false) assert.Error(t, err) assert.False(t, valid) assert.Contains(t, err.Error(), "is not a directory") }) } func TestDirectoryExists(t *testing.T) { // Test with existing directory t.Run("existing directory", func(t *testing.T) { tempDir := t.TempDir() exists, err := directoryExists(tempDir) require.NoError(t, err) assert.True(t, exists) }) // Test with non-existing directory t.Run("non-existing directory", func(t *testing.T) { tempDir := t.TempDir() nonExistentDir := filepath.Join(tempDir, "does-not-exist") exists, err := directoryExists(nonExistentDir) require.NoError(t, err) assert.False(t, exists) }) // Test with file instead of directory t.Run("file instead of directory", func(t *testing.T) { tempDir := t.TempDir() tempFile := filepath.Join(tempDir, "testfile") err := os.WriteFile(tempFile, []byte("test"), 0644) require.NoError(t, err) exists, err := directoryExists(tempFile) assert.Error(t, err) assert.False(t, exists) assert.Contains(t, err.Error(), "is not a directory") }) } func TestDirectoryIsWritable(t *testing.T) { // Test with writable directory t.Run("writable directory", func(t *testing.T) { tempDir := t.TempDir() writable, err := directoryIsWritable(tempDir) require.NoError(t, err) assert.True(t, writable) }) // Test with non-existing directory t.Run("non-existing directory", func(t *testing.T) { tempDir := t.TempDir() nonExistentDir := filepath.Join(tempDir, "does-not-exist") writable, err := directoryIsWritable(nonExistentDir) assert.Error(t, err) assert.False(t, writable) }) // Test with non-writable directory (Unix-like systems only) t.Run("non-writable directory", func(t *testing.T) { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { t.Skip("Skipping non-writable directory test on", runtime.GOOS) } tempDir := t.TempDir() readOnlyDir := filepath.Join(tempDir, "readonly") // Create the directory err := os.Mkdir(readOnlyDir, 0755) require.NoError(t, err) // Make it read-only err = os.Chmod(readOnlyDir, 0444) require.NoError(t, err) // Restore permissions after test for cleanup defer func() { os.Chmod(readOnlyDir, 0755) }() writable, err := directoryIsWritable(readOnlyDir) assert.Error(t, err) assert.False(t, writable) }) } ================================================ FILE: agent/deltatracker/deltatracker.go ================================================ // Package deltatracker provides a tracker for calculating differences in numeric values over time. package deltatracker import ( "sync" "golang.org/x/exp/constraints" ) // Numeric is a constraint that permits any integer or floating-point type. type Numeric interface { constraints.Integer | constraints.Float } // DeltaTracker is a generic, thread-safe tracker for calculating differences // in numeric values over time. // K is the key type (e.g., int, string). // V is the value type (e.g., int, int64, float32, float64). type DeltaTracker[K comparable, V Numeric] struct { sync.RWMutex current map[K]V previous map[K]V } // NewDeltaTracker creates a new generic tracker. func NewDeltaTracker[K comparable, V Numeric]() *DeltaTracker[K, V] { return &DeltaTracker[K, V]{ current: make(map[K]V), previous: make(map[K]V), } } // Set records the current value for a given ID. func (t *DeltaTracker[K, V]) Set(id K, value V) { t.Lock() defer t.Unlock() t.current[id] = value } // Snapshot returns a copy of the current map. // func (t *DeltaTracker[K, V]) Snapshot() map[K]V { // t.RLock() // defer t.RUnlock() // copyMap := make(map[K]V, len(t.current)) // maps.Copy(copyMap, t.current) // return copyMap // } // Deltas returns a map of all calculated deltas for the current interval. func (t *DeltaTracker[K, V]) Deltas() map[K]V { t.RLock() defer t.RUnlock() deltas := make(map[K]V) for id, currentVal := range t.current { if previousVal, ok := t.previous[id]; ok { deltas[id] = currentVal - previousVal } else { deltas[id] = 0 } } return deltas } // Previous returns the previously recorded value for the given key, if it exists. func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) { t.RLock() defer t.RUnlock() value, ok := t.previous[id] return value, ok } // Delta returns the delta for a single key. // Returns 0 if the key doesn't exist or has no previous value. func (t *DeltaTracker[K, V]) Delta(id K) V { t.RLock() defer t.RUnlock() currentVal, currentOk := t.current[id] if !currentOk { return 0 } previousVal, previousOk := t.previous[id] if !previousOk { return 0 } return currentVal - previousVal } // Cycle prepares the tracker for the next interval. func (t *DeltaTracker[K, V]) Cycle() { t.Lock() defer t.Unlock() t.previous = t.current t.current = make(map[K]V) } ================================================ FILE: agent/deltatracker/deltatracker_test.go ================================================ package deltatracker import ( "fmt" "testing" "github.com/stretchr/testify/assert" ) func ExampleDeltaTracker() { tracker := NewDeltaTracker[string, int]() tracker.Set("key1", 10) tracker.Set("key2", 20) tracker.Cycle() tracker.Set("key1", 15) tracker.Set("key2", 30) fmt.Println(tracker.Delta("key1")) fmt.Println(tracker.Delta("key2")) fmt.Println(tracker.Deltas()) // Output: 5 // 10 // map[key1:5 key2:10] } func TestNewDeltaTracker(t *testing.T) { tracker := NewDeltaTracker[string, int]() assert.NotNil(t, tracker) assert.Empty(t, tracker.current) assert.Empty(t, tracker.previous) } func TestSet(t *testing.T) { tracker := NewDeltaTracker[string, int]() tracker.Set("key1", 10) tracker.RLock() defer tracker.RUnlock() assert.Equal(t, 10, tracker.current["key1"]) } func TestDeltas(t *testing.T) { tracker := NewDeltaTracker[string, int]() // Test with no previous values tracker.Set("key1", 10) tracker.Set("key2", 20) deltas := tracker.Deltas() assert.Equal(t, 0, deltas["key1"]) assert.Equal(t, 0, deltas["key2"]) // Cycle to move current to previous tracker.Cycle() // Set new values and check deltas tracker.Set("key1", 15) // Delta should be 5 (15-10) tracker.Set("key2", 25) // Delta should be 5 (25-20) tracker.Set("key3", 30) // New key, delta should be 0 deltas = tracker.Deltas() assert.Equal(t, 5, deltas["key1"]) assert.Equal(t, 5, deltas["key2"]) assert.Equal(t, 0, deltas["key3"]) } func TestCycle(t *testing.T) { tracker := NewDeltaTracker[string, int]() tracker.Set("key1", 10) tracker.Set("key2", 20) // Verify current has values tracker.RLock() assert.Equal(t, 10, tracker.current["key1"]) assert.Equal(t, 20, tracker.current["key2"]) assert.Empty(t, tracker.previous) tracker.RUnlock() tracker.Cycle() // After cycle, previous should have the old current values // and current should be empty tracker.RLock() assert.Empty(t, tracker.current) assert.Equal(t, 10, tracker.previous["key1"]) assert.Equal(t, 20, tracker.previous["key2"]) tracker.RUnlock() } func TestCompleteWorkflow(t *testing.T) { tracker := NewDeltaTracker[string, int]() // First interval tracker.Set("server1", 100) tracker.Set("server2", 200) // Get deltas for first interval (should be zero) firstDeltas := tracker.Deltas() assert.Equal(t, 0, firstDeltas["server1"]) assert.Equal(t, 0, firstDeltas["server2"]) // Cycle to next interval tracker.Cycle() // Second interval tracker.Set("server1", 150) // Delta: 50 tracker.Set("server2", 180) // Delta: -20 tracker.Set("server3", 300) // New server, delta: 300 secondDeltas := tracker.Deltas() assert.Equal(t, 50, secondDeltas["server1"]) assert.Equal(t, -20, secondDeltas["server2"]) assert.Equal(t, 0, secondDeltas["server3"]) } func TestDeltaTrackerWithDifferentTypes(t *testing.T) { // Test with int64 intTracker := NewDeltaTracker[string, int64]() intTracker.Set("pid1", 1000) intTracker.Cycle() intTracker.Set("pid1", 1200) intDeltas := intTracker.Deltas() assert.Equal(t, int64(200), intDeltas["pid1"]) // Test with float64 floatTracker := NewDeltaTracker[string, float64]() floatTracker.Set("cpu1", 1.5) floatTracker.Cycle() floatTracker.Set("cpu1", 2.7) floatDeltas := floatTracker.Deltas() assert.InDelta(t, 1.2, floatDeltas["cpu1"], 0.0001) // Test with int keys pidTracker := NewDeltaTracker[int, int64]() pidTracker.Set(101, 20000) pidTracker.Cycle() pidTracker.Set(101, 22500) pidDeltas := pidTracker.Deltas() assert.Equal(t, int64(2500), pidDeltas[101]) } func TestDelta(t *testing.T) { tracker := NewDeltaTracker[string, int]() // Test getting delta for non-existent key result := tracker.Delta("nonexistent") assert.Equal(t, 0, result) // Test getting delta for key with no previous value tracker.Set("key1", 10) result = tracker.Delta("key1") assert.Equal(t, 0, result) // Cycle to move current to previous tracker.Cycle() // Test getting delta for key with previous value tracker.Set("key1", 15) result = tracker.Delta("key1") assert.Equal(t, 5, result) // Test getting delta for key that exists in previous but not current result = tracker.Delta("key1") assert.Equal(t, 5, result) // Should still return 5 // Test getting delta for key that exists in current but not previous tracker.Set("key2", 20) result = tracker.Delta("key2") assert.Equal(t, 0, result) } func TestDeltaWithDifferentTypes(t *testing.T) { // Test with int64 intTracker := NewDeltaTracker[string, int64]() intTracker.Set("pid1", 1000) intTracker.Cycle() intTracker.Set("pid1", 1200) result := intTracker.Delta("pid1") assert.Equal(t, int64(200), result) // Test with float64 floatTracker := NewDeltaTracker[string, float64]() floatTracker.Set("cpu1", 1.5) floatTracker.Cycle() floatTracker.Set("cpu1", 2.7) floatResult := floatTracker.Delta("cpu1") assert.InDelta(t, 1.2, floatResult, 0.0001) // Test with int keys pidTracker := NewDeltaTracker[int, int64]() pidTracker.Set(101, 20000) pidTracker.Cycle() pidTracker.Set(101, 22500) pidResult := pidTracker.Delta(101) assert.Equal(t, int64(2500), pidResult) } func TestDeltaConcurrentAccess(t *testing.T) { tracker := NewDeltaTracker[string, int]() // Set initial values tracker.Set("key1", 10) tracker.Set("key2", 20) tracker.Cycle() // Set new values tracker.Set("key1", 15) tracker.Set("key2", 25) // Test concurrent access safety result1 := tracker.Delta("key1") result2 := tracker.Delta("key2") assert.Equal(t, 5, result1) assert.Equal(t, 5, result2) } ================================================ FILE: agent/disk.go ================================================ package agent import ( "log/slog" "os" "path/filepath" "runtime" "strings" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/disk" ) // fsRegistrationContext holds the shared lookup state needed to resolve a // filesystem into the tracked fsStats key and metadata. type fsRegistrationContext struct { filesystem string // value of optional FILESYSTEM env var isWindows bool efPath string // path to extra filesystems (default "/extra-filesystems") diskIoCounters map[string]disk.IOCountersStat } // diskDiscovery groups the transient state for a single initializeDiskInfo run so // helper methods can share the same partitions, mount paths, and lookup functions type diskDiscovery struct { agent *Agent rootMountPoint string partitions []disk.PartitionStat usageFn func(string) (*disk.UsageStat, error) ctx fsRegistrationContext } // parseFilesystemEntry parses a filesystem entry in the format "device__customname" // Returns the device/filesystem part and the custom name part func parseFilesystemEntry(entry string) (device, customName string) { entry = strings.TrimSpace(entry) if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 { device = strings.TrimSpace(parts[0]) customName = strings.TrimSpace(parts[1]) } else { device = entry } return device, customName } // extraFilesystemPartitionInfo derives the I/O device and optional display name // for a mounted /extra-filesystems partition. Prefer the partition device reported // by the system and only use the folder name for custom naming metadata. func extraFilesystemPartitionInfo(p disk.PartitionStat) (device, customName string) { device = strings.TrimSpace(p.Device) folderDevice, customName := parseFilesystemEntry(filepath.Base(p.Mountpoint)) if device == "" { device = folderDevice } return device, customName } func isDockerSpecialMountpoint(mountpoint string) bool { switch mountpoint { case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname": return true } return false } // registerFilesystemStats resolves the tracked key and stats payload for a // filesystem before it is inserted into fsStats. func registerFilesystemStats(existing map[string]*system.FsStats, device, mountpoint string, root bool, customName string, ctx fsRegistrationContext) (string, *system.FsStats, bool) { key := device if !ctx.isWindows { key = filepath.Base(device) } if root { // Try to map root device to a diskIoCounters entry. First checks for an // exact key match, then uses findIoDevice for normalized / prefix-based // matching (e.g. nda0p2 -> nda0), and finally falls back to FILESYSTEM. if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch { if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match { key = matchedKey } else if ctx.filesystem != "" { if matchedKey, match := findIoDevice(ctx.filesystem, ctx.diskIoCounters); match { key = matchedKey } } if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch { slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint) } } } else { // Check if non-root has diskstats and prefer the folder device for // /extra-filesystems mounts when the discovered partition device is a // mapper path (e.g. luks UUID) that obscures the underlying block device. if _, ioMatch := ctx.diskIoCounters[key]; !ioMatch { if strings.HasPrefix(mountpoint, ctx.efPath) { folderDevice, _ := parseFilesystemEntry(filepath.Base(mountpoint)) if folderDevice != "" { if matchedKey, match := findIoDevice(folderDevice, ctx.diskIoCounters); match { key = matchedKey } } } if _, ioMatch = ctx.diskIoCounters[key]; !ioMatch { if matchedKey, match := findIoDevice(key, ctx.diskIoCounters); match { key = matchedKey } } } } if _, exists := existing[key]; exists { return "", nil, false } fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint} if customName != "" { fsStats.Name = customName } return key, fsStats, true } // addFsStat inserts a discovered filesystem if it resolves to a new tracking // key. The key selection itself lives in buildFsStatRegistration so that logic // can stay directly unit-tested. func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) { key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx) if !ok { return } d.agent.fsStats[key] = fsStats name := key if customName != "" { name = customName } slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root) } // addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls // back to direct diskstats matching for setups like ZFS where partitions do not // expose the physical device name. func (d *diskDiscovery) addConfiguredRootFs() bool { if d.ctx.filesystem == "" { return false } for _, p := range d.partitions { if filesystemMatchesPartitionSetting(d.ctx.filesystem, p) { d.addFsStat(p.Device, p.Mountpoint, true, "") return true } } // FILESYSTEM may name a physical disk absent from partitions (e.g. ZFS lists // dataset paths like zroot/ROOT/default, not block devices). if ioKey, match := findIoDevice(d.ctx.filesystem, d.ctx.diskIoCounters); match { d.agent.fsStats[ioKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint} return true } slog.Warn("Partition details not found", "filesystem", d.ctx.filesystem) return false } func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool { return p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev")) } // addPartitionRootFs handles the non-configured root fallback path when a // partition looks like the active root mount but still needs translating to an // I/O device key. func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool { fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters) if !match { return false } // The resolved I/O device is already known here, so use it directly to avoid // a second fallback search inside buildFsStatRegistration. d.addFsStat(fs, mountpoint, true, "") return true } // addLastResortRootFs is only used when neither FILESYSTEM nor partition-based // heuristics can identify root, so it picks the busiest I/O device as a final // fallback and preserves the root mountpoint for usage collection. func (d *diskDiscovery) addLastResortRootFs() { rootKey := mostActiveIoDevice(d.ctx.diskIoCounters) if rootKey != "" { slog.Warn("Using most active device for root I/O; set FILESYSTEM to override", "device", rootKey) } else { rootKey = filepath.Base(d.rootMountPoint) if _, exists := d.agent.fsStats[rootKey]; exists { rootKey = "root" } slog.Warn("Root I/O device not detected; set FILESYSTEM to override") } d.agent.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: d.rootMountPoint} } // findPartitionByFilesystemSetting matches an EXTRA_FILESYSTEMS entry against a // discovered partition either by mountpoint or by device suffix. func findPartitionByFilesystemSetting(filesystem string, partitions []disk.PartitionStat) (disk.PartitionStat, bool) { for _, p := range partitions { if strings.HasSuffix(p.Device, filesystem) || p.Mountpoint == filesystem { return p, true } } return disk.PartitionStat{}, false } // addConfiguredExtraFsEntry resolves one EXTRA_FILESYSTEMS entry, preferring a // discovered partition and falling back to any path that disk.Usage accepts. func (d *diskDiscovery) addConfiguredExtraFsEntry(filesystem, customName string) { if p, found := findPartitionByFilesystemSetting(filesystem, d.partitions); found { d.addFsStat(p.Device, p.Mountpoint, false, customName) return } if _, err := d.usageFn(filesystem); err == nil { d.addFsStat(filepath.Base(filesystem), filesystem, false, customName) return } else { slog.Error("Invalid filesystem", "name", filesystem, "err", err) } } // addConfiguredExtraFilesystems parses and registers the comma-separated // EXTRA_FILESYSTEMS env var entries. func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) { for fsEntry := range strings.SplitSeq(extraFilesystems, ",") { filesystem, customName := parseFilesystemEntry(fsEntry) d.addConfiguredExtraFsEntry(filesystem, customName) } } // addPartitionExtraFs registers partitions mounted under /extra-filesystems so // their display names can come from the folder name while their I/O keys still // prefer the underlying partition device. func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) { if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) { return } device, customName := extraFilesystemPartitionInfo(p) d.addFsStat(device, p.Mountpoint, false, customName) } // addExtraFilesystemFolders handles bare directories under /extra-filesystems // that may not appear in partition discovery, while skipping mountpoints that // were already registered from higher-fidelity sources. func (d *diskDiscovery) addExtraFilesystemFolders(folderNames []string) { existingMountpoints := make(map[string]bool, len(d.agent.fsStats)) for _, stats := range d.agent.fsStats { existingMountpoints[stats.Mountpoint] = true } for _, folderName := range folderNames { mountpoint := filepath.Join(d.ctx.efPath, folderName) slog.Debug("/extra-filesystems", "mountpoint", mountpoint) if existingMountpoints[mountpoint] { continue } device, customName := parseFilesystemEntry(folderName) d.addFsStat(device, mountpoint, false, customName) } } // Sets up the filesystems to monitor for disk usage and I/O. func (a *Agent) initializeDiskInfo() { filesystem, _ := utils.GetEnv("FILESYSTEM") hasRoot := false isWindows := runtime.GOOS == "windows" partitions, err := disk.Partitions(false) if err != nil { slog.Error("Error getting disk partitions", "err", err) } slog.Debug("Disk", "partitions", partitions) // trim trailing backslash for Windows devices (#1361) if isWindows { for i, p := range partitions { partitions[i].Device = strings.TrimSuffix(p.Device, "\\") } } diskIoCounters, err := disk.IOCounters() if err != nil { slog.Error("Error getting diskstats", "err", err) } slog.Debug("Disk I/O", "diskstats", diskIoCounters) ctx := fsRegistrationContext{ filesystem: filesystem, isWindows: isWindows, diskIoCounters: diskIoCounters, efPath: "/extra-filesystems", } // Get the appropriate root mount point for this system discovery := diskDiscovery{ agent: a, rootMountPoint: a.getRootMountPoint(), partitions: partitions, usageFn: disk.Usage, ctx: ctx, } hasRoot = discovery.addConfiguredRootFs() // Add EXTRA_FILESYSTEMS env var values to fsStats if extraFilesystems, exists := utils.GetEnv("EXTRA_FILESYSTEMS"); exists { discovery.addConfiguredExtraFilesystems(extraFilesystems) } // Process partitions for various mount points for _, p := range partitions { if !hasRoot && isRootFallbackPartition(p, discovery.rootMountPoint) { hasRoot = discovery.addPartitionRootFs(p.Device, p.Mountpoint) } discovery.addPartitionExtraFs(p) } // Check all folders in /extra-filesystems and add them if not already present if folders, err := os.ReadDir(discovery.ctx.efPath); err == nil { folderNames := make([]string, 0, len(folders)) for _, folder := range folders { if folder.IsDir() { folderNames = append(folderNames, folder.Name()) } } discovery.addExtraFilesystemFolders(folderNames) } // If no root filesystem set, try the most active I/O device as a last // resort (e.g. ZFS where dataset names are unrelated to disk names). if !hasRoot { discovery.addLastResortRootFs() } a.pruneDuplicateRootExtraFilesystems() a.initializeDiskIoStats(diskIoCounters) } // Removes extra filesystems that mirror root usage (https://github.com/henrygd/beszel/issues/1428). func (a *Agent) pruneDuplicateRootExtraFilesystems() { var rootMountpoint string for _, stats := range a.fsStats { if stats != nil && stats.Root { rootMountpoint = stats.Mountpoint break } } if rootMountpoint == "" { return } rootUsage, err := disk.Usage(rootMountpoint) if err != nil { return } for name, stats := range a.fsStats { if stats == nil || stats.Root { continue } extraUsage, err := disk.Usage(stats.Mountpoint) if err != nil { continue } if hasSameDiskUsage(rootUsage, extraUsage) { slog.Info("Ignoring duplicate FS", "name", name, "mount", stats.Mountpoint) delete(a.fsStats, name) } } } // hasSameDiskUsage compares root/extra usage with a small byte tolerance. func hasSameDiskUsage(a, b *disk.UsageStat) bool { if a == nil || b == nil || a.Total == 0 || b.Total == 0 { return false } // Allow minor drift between sequential disk usage calls. const toleranceBytes uint64 = 16 * 1024 * 1024 return withinUsageTolerance(a.Total, b.Total, toleranceBytes) && withinUsageTolerance(a.Used, b.Used, toleranceBytes) } // withinUsageTolerance reports whether two byte values differ by at most tolerance. func withinUsageTolerance(a, b, tolerance uint64) bool { if a >= b { return a-b <= tolerance } return b-a <= tolerance } type ioMatchCandidate struct { name string bytes uint64 ops uint64 } // findIoDevice prefers exact device/label matches, then falls back to a // prefix-related candidate with the highest recent activity. func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) { filesystem = normalizeDeviceName(filesystem) if filesystem == "" { return "", false } candidates := []ioMatchCandidate{} for _, d := range diskIoCounters { if normalizeDeviceName(d.Name) == filesystem || (d.Label != "" && normalizeDeviceName(d.Label) == filesystem) { return d.Name, true } if prefixRelated(normalizeDeviceName(d.Name), filesystem) || (d.Label != "" && prefixRelated(normalizeDeviceName(d.Label), filesystem)) { candidates = append(candidates, ioMatchCandidate{ name: d.Name, bytes: d.ReadBytes + d.WriteBytes, ops: d.ReadCount + d.WriteCount, }) } } if len(candidates) == 0 { return "", false } best := candidates[0] for _, c := range candidates[1:] { if c.bytes > best.bytes || (c.bytes == best.bytes && c.ops > best.ops) || (c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) { best = c } } slog.Info("Using disk I/O fallback", "requested", filesystem, "selected", best.name) return best.name, true } // mostActiveIoDevice returns the device with the highest I/O activity, // or "" if diskIoCounters is empty. func mostActiveIoDevice(diskIoCounters map[string]disk.IOCountersStat) string { var best ioMatchCandidate for _, d := range diskIoCounters { c := ioMatchCandidate{ name: d.Name, bytes: d.ReadBytes + d.WriteBytes, ops: d.ReadCount + d.WriteCount, } if best.name == "" || c.bytes > best.bytes || (c.bytes == best.bytes && c.ops > best.ops) || (c.bytes == best.bytes && c.ops == best.ops && c.name < best.name) { best = c } } return best.name } // prefixRelated reports whether either identifier is a prefix of the other. func prefixRelated(a, b string) bool { if a == "" || b == "" || a == b { return false } return strings.HasPrefix(a, b) || strings.HasPrefix(b, a) } // filesystemMatchesPartitionSetting checks whether a FILESYSTEM env var value // matches a partition by mountpoint, exact device name, or prefix relationship // (e.g. FILESYSTEM=ada0 matches partition /dev/ada0p2). func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat) bool { filesystem = strings.TrimSpace(filesystem) if filesystem == "" { return false } if p.Mountpoint == filesystem { return true } fsName := normalizeDeviceName(filesystem) partName := normalizeDeviceName(p.Device) if fsName == "" || partName == "" { return false } if fsName == partName { return true } return prefixRelated(partName, fsName) } // normalizeDeviceName canonicalizes device strings for comparisons. func normalizeDeviceName(value string) string { name := filepath.Base(strings.TrimSpace(value)) if name == "." { return "" } return name } // Sets start values for disk I/O stats. func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) { a.fsNames = a.fsNames[:0] now := time.Now() for device, stats := range a.fsStats { // skip if not in diskIoCounters d, exists := diskIoCounters[device] if !exists { slog.Warn("Device not found in diskstats", "name", device) continue } // populate initial values stats.Time = now stats.TotalRead = d.ReadBytes stats.TotalWrite = d.WriteBytes // add to list of valid io device names a.fsNames = append(a.fsNames, device) } } // Updates disk usage statistics for all monitored filesystems func (a *Agent) updateDiskUsage(systemStats *system.Stats) { // Check if we should skip extra filesystem collection to avoid waking sleeping disks. // Root filesystem is always updated since it can't be sleeping while the agent runs. // Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled. cacheExtraFs := a.diskUsageCacheDuration > 0 && !a.lastDiskUsageUpdate.IsZero() && time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration // disk usage for _, stats := range a.fsStats { // Skip non-root filesystems if caching is active if cacheExtraFs && !stats.Root { continue } if d, err := disk.Usage(stats.Mountpoint); err == nil { stats.DiskTotal = utils.BytesToGigabytes(d.Total) stats.DiskUsed = utils.BytesToGigabytes(d.Used) if stats.Root { systemStats.DiskTotal = utils.BytesToGigabytes(d.Total) systemStats.DiskUsed = utils.BytesToGigabytes(d.Used) systemStats.DiskPct = utils.TwoDecimals(d.UsedPercent) } } else { // reset stats if error (likely unmounted) slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err) stats.DiskTotal = 0 stats.DiskUsed = 0 stats.TotalRead = 0 stats.TotalWrite = 0 } } // Update the last disk usage update time when we've collected extra filesystems if !cacheExtraFs { a.lastDiskUsageUpdate = time.Now() } } // Updates disk I/O statistics for all monitored filesystems func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) { // disk i/o (cache-aware per interval) if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil { // Ensure map for this interval exists if _, ok := a.diskPrev[cacheTimeMs]; !ok { a.diskPrev[cacheTimeMs] = make(map[string]prevDisk) } now := time.Now() for name, d := range ioCounters { stats := a.fsStats[d.Name] if stats == nil { // skip devices not tracked continue } // Previous snapshot for this interval and device prev, hasPrev := a.diskPrev[cacheTimeMs][name] if !hasPrev { // Seed from agent-level fsStats if present, else seed from current prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time} if prev.at.IsZero() { prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} } } msElapsed := uint64(now.Sub(prev.at).Milliseconds()) if msElapsed < 100 { // Avoid division by zero or clock issues; update snapshot and continue a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} continue } diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed readMbPerSecond := utils.BytesToMegabytes(float64(diskIORead)) writeMbPerSecond := utils.BytesToMegabytes(float64(diskIOWrite)) // validate values if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 { slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond) // Reset interval snapshot and seed from current a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} // also refresh agent baseline to avoid future negatives a.initializeDiskIoStats(ioCounters) continue } // Update per-interval snapshot a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now} // Update global fsStats baseline for cross-interval correctness stats.Time = now stats.TotalRead = d.ReadBytes stats.TotalWrite = d.WriteBytes stats.DiskReadPs = readMbPerSecond stats.DiskWritePs = writeMbPerSecond stats.DiskReadBytes = diskIORead stats.DiskWriteBytes = diskIOWrite if stats.Root { systemStats.DiskReadPs = stats.DiskReadPs systemStats.DiskWritePs = stats.DiskWritePs systemStats.DiskIO[0] = diskIORead systemStats.DiskIO[1] = diskIOWrite } } } } // getRootMountPoint returns the appropriate root mount point for the system // For immutable systems like Fedora Silverblue, it returns /sysroot instead of / func (a *Agent) getRootMountPoint() string { // 1. Check if /etc/os-release contains indicators of an immutable system if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil { content := string(osReleaseContent) if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") || strings.Contains(content, "coreos") || strings.Contains(content, "flatcar") || strings.Contains(content, "rhel-atomic") || strings.Contains(content, "centos-atomic") { // Verify that /sysroot exists before returning it if _, err := os.Stat("/sysroot"); err == nil { return "/sysroot" } } } // 2. Check if /run/ostree is present (ostree-based systems like Silverblue) if _, err := os.Stat("/run/ostree"); err == nil { // Verify that /sysroot exists before returning it if _, err := os.Stat("/sysroot"); err == nil { return "/sysroot" } } return "/" } ================================================ FILE: agent/disk_test.go ================================================ //go:build testing package agent import ( "os" "strings" "testing" "time" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/disk" "github.com/stretchr/testify/assert" ) func TestParseFilesystemEntry(t *testing.T) { tests := []struct { name string input string expectedFs string expectedName string }{ { name: "simple device name", input: "sda1", expectedFs: "sda1", expectedName: "", }, { name: "device with custom name", input: "sda1__my-storage", expectedFs: "sda1", expectedName: "my-storage", }, { name: "full device path with custom name", input: "/dev/sdb1__backup-drive", expectedFs: "/dev/sdb1", expectedName: "backup-drive", }, { name: "NVMe device with custom name", input: "nvme0n1p2__fast-ssd", expectedFs: "nvme0n1p2", expectedName: "fast-ssd", }, { name: "whitespace trimmed", input: " sda2__trimmed-name ", expectedFs: "sda2", expectedName: "trimmed-name", }, { name: "empty custom name", input: "sda3__", expectedFs: "sda3", expectedName: "", }, { name: "empty device name", input: "__just-custom", expectedFs: "", expectedName: "just-custom", }, { name: "multiple underscores in custom name", input: "sda1__my_custom_drive", expectedFs: "sda1", expectedName: "my_custom_drive", }, { name: "custom name with spaces", input: "sda1__My Storage Drive", expectedFs: "sda1", expectedName: "My Storage Drive", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fsEntry := strings.TrimSpace(tt.input) var fs, customName string if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 { fs = strings.TrimSpace(parts[0]) customName = strings.TrimSpace(parts[1]) } else { fs = fsEntry } assert.Equal(t, tt.expectedFs, fs) assert.Equal(t, tt.expectedName, customName) }) } } func TestExtraFilesystemPartitionInfo(t *testing.T) { t.Run("uses partition device for label-only mountpoint", func(t *testing.T) { device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{ Device: "/dev/sdc", Mountpoint: "/extra-filesystems/Share", }) assert.Equal(t, "/dev/sdc", device) assert.Equal(t, "", customName) }) t.Run("uses custom name from mountpoint suffix", func(t *testing.T) { device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{ Device: "/dev/sdc", Mountpoint: "/extra-filesystems/sdc__Share", }) assert.Equal(t, "/dev/sdc", device) assert.Equal(t, "Share", customName) }) t.Run("falls back to folder device when partition device is unavailable", func(t *testing.T) { device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{ Mountpoint: "/extra-filesystems/sdc__Share", }) assert.Equal(t, "sdc", device) assert.Equal(t, "Share", customName) }) t.Run("supports custom name without folder device prefix", func(t *testing.T) { device, customName := extraFilesystemPartitionInfo(disk.PartitionStat{ Device: "/dev/sdc", Mountpoint: "/extra-filesystems/__Share", }) assert.Equal(t, "/dev/sdc", device) assert.Equal(t, "Share", customName) }) } func TestBuildFsStatRegistration(t *testing.T) { t.Run("uses basename for non-windows exact io match", func(t *testing.T) { key, stats, ok := registerFilesystemStats( map[string]*system.FsStats{}, "/dev/sda1", "/mnt/data", false, "archive", fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "sda1": {Name: "sda1"}, }, }, ) assert.True(t, ok) assert.Equal(t, "sda1", key) assert.Equal(t, "/mnt/data", stats.Mountpoint) assert.Equal(t, "archive", stats.Name) assert.False(t, stats.Root) }) t.Run("maps root partition to io device by prefix", func(t *testing.T) { key, stats, ok := registerFilesystemStats( map[string]*system.FsStats{}, "/dev/ada0p2", "/", true, "", fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000}, }, }, ) assert.True(t, ok) assert.Equal(t, "ada0", key) assert.True(t, stats.Root) assert.Equal(t, "/", stats.Mountpoint) }) t.Run("uses filesystem setting as root fallback", func(t *testing.T) { key, _, ok := registerFilesystemStats( map[string]*system.FsStats{}, "overlay", "/", true, "", fsRegistrationContext{ filesystem: "nvme0n1p2", isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000}, }, }, ) assert.True(t, ok) assert.Equal(t, "nvme0n1", key) }) t.Run("prefers parsed extra-filesystems device over mapper device", func(t *testing.T) { key, stats, ok := registerFilesystemStats( map[string]*system.FsStats{}, "/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e", "/extra-filesystems/nvme0n1p2__Archive", false, "Archive", fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"}, "nvme0n1p2": {Name: "nvme0n1p2"}, }, }, ) assert.True(t, ok) assert.Equal(t, "nvme0n1p2", key) assert.Equal(t, "Archive", stats.Name) }) t.Run("falls back to mapper io device when folder device cannot be resolved", func(t *testing.T) { key, stats, ok := registerFilesystemStats( map[string]*system.FsStats{}, "/dev/mapper/luks-2bcb02be-999d-4417-8d18-5c61e660fb6e", "/extra-filesystems/Archive", false, "Archive", fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "dm-1": {Name: "dm-1", Label: "luks-2bcb02be-999d-4417-8d18-5c61e660fb6e"}, }, }, ) assert.True(t, ok) assert.Equal(t, "dm-1", key) assert.Equal(t, "Archive", stats.Name) }) t.Run("uses full device name on windows", func(t *testing.T) { key, _, ok := registerFilesystemStats( map[string]*system.FsStats{}, `C:`, `C:\\`, false, "", fsRegistrationContext{ isWindows: true, diskIoCounters: map[string]disk.IOCountersStat{ `C:`: {Name: `C:`}, }, }, ) assert.True(t, ok) assert.Equal(t, `C:`, key) }) t.Run("skips existing key", func(t *testing.T) { key, stats, ok := registerFilesystemStats( map[string]*system.FsStats{"sda1": {Mountpoint: "/existing"}}, "/dev/sda1", "/mnt/data", false, "", fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "sda1": {Name: "sda1"}, }, }, ) assert.False(t, ok) assert.Empty(t, key) assert.Nil(t, stats) }) } func TestAddConfiguredRootFs(t *testing.T) { t.Run("adds root from matching partition", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, rootMountPoint: "/", partitions: []disk.PartitionStat{{Device: "/dev/ada0p2", Mountpoint: "/"}}, ctx: fsRegistrationContext{ filesystem: "/dev/ada0p2", isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "ada0": {Name: "ada0", ReadBytes: 1000, WriteBytes: 1000}, }, }, } ok := discovery.addConfiguredRootFs() assert.True(t, ok) stats, exists := agent.fsStats["ada0"] assert.True(t, exists) assert.True(t, stats.Root) assert.Equal(t, "/", stats.Mountpoint) }) t.Run("adds root from io device when partition is missing", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{ filesystem: "zroot", isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "nda0": {Name: "nda0", Label: "zroot", ReadBytes: 1000, WriteBytes: 1000}, }, }, } ok := discovery.addConfiguredRootFs() assert.True(t, ok) stats, exists := agent.fsStats["nda0"] assert.True(t, exists) assert.True(t, stats.Root) assert.Equal(t, "/sysroot", stats.Mountpoint) }) t.Run("returns false when filesystem cannot be resolved", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{ filesystem: "missing-disk", isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{}, }, } ok := discovery.addConfiguredRootFs() assert.False(t, ok) assert.Empty(t, agent.fsStats) }) } func TestAddPartitionRootFs(t *testing.T) { t.Run("adds root from fallback partition candidate", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, ctx: fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "nvme0n1": {Name: "nvme0n1", ReadBytes: 1000, WriteBytes: 1000}, }, }, } ok := discovery.addPartitionRootFs("/dev/nvme0n1p2", "/") assert.True(t, ok) stats, exists := agent.fsStats["nvme0n1"] assert.True(t, exists) assert.True(t, stats.Root) assert.Equal(t, "/", stats.Mountpoint) }) t.Run("returns false when no io device matches", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{agent: agent, ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}} ok := discovery.addPartitionRootFs("/dev/mapper/root", "/") assert.False(t, ok) assert.Empty(t, agent.fsStats) }) } func TestAddLastResortRootFs(t *testing.T) { t.Run("uses most active io device when available", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{agent: agent, rootMountPoint: "/", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{ "sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000}, "sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000}, }}} discovery.addLastResortRootFs() stats, exists := agent.fsStats["sda"] assert.True(t, exists) assert.True(t, stats.Root) }) t.Run("falls back to root key when mountpoint basename collides", func(t *testing.T) { agent := &Agent{fsStats: map[string]*system.FsStats{ "sysroot": {Mountpoint: "/extra-filesystems/sysroot"}, }} discovery := diskDiscovery{agent: agent, rootMountPoint: "/sysroot", ctx: fsRegistrationContext{diskIoCounters: map[string]disk.IOCountersStat{}}} discovery.addLastResortRootFs() stats, exists := agent.fsStats["root"] assert.True(t, exists) assert.True(t, stats.Root) assert.Equal(t, "/sysroot", stats.Mountpoint) }) } func TestAddConfiguredExtraFsEntry(t *testing.T) { t.Run("uses matching partition when present", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, partitions: []disk.PartitionStat{{Device: "/dev/sdb1", Mountpoint: "/mnt/backup"}}, usageFn: func(string) (*disk.UsageStat, error) { t.Fatal("usage fallback should not be called when partition matches") return nil, nil }, ctx: fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "sdb1": {Name: "sdb1"}, }, }, } discovery.addConfiguredExtraFsEntry("sdb1", "backup") stats, exists := agent.fsStats["sdb1"] assert.True(t, exists) assert.Equal(t, "/mnt/backup", stats.Mountpoint) assert.Equal(t, "backup", stats.Name) }) t.Run("falls back to usage-validated path", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, usageFn: func(path string) (*disk.UsageStat, error) { assert.Equal(t, "/srv/archive", path) return &disk.UsageStat{}, nil }, ctx: fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "archive": {Name: "archive"}, }, }, } discovery.addConfiguredExtraFsEntry("/srv/archive", "archive") stats, exists := agent.fsStats["archive"] assert.True(t, exists) assert.Equal(t, "/srv/archive", stats.Mountpoint) assert.Equal(t, "archive", stats.Name) }) t.Run("ignores invalid filesystem entry", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, usageFn: func(string) (*disk.UsageStat, error) { return nil, os.ErrNotExist }, } discovery.addConfiguredExtraFsEntry("/missing/archive", "") assert.Empty(t, agent.fsStats) }) } func TestAddConfiguredExtraFilesystems(t *testing.T) { t.Run("parses and registers multiple configured filesystems", func(t *testing.T) { agent := &Agent{fsStats: make(map[string]*system.FsStats)} discovery := diskDiscovery{ agent: agent, partitions: []disk.PartitionStat{{Device: "/dev/sda1", Mountpoint: "/mnt/fast"}}, usageFn: func(path string) (*disk.UsageStat, error) { if path == "/srv/archive" { return &disk.UsageStat{}, nil } return nil, os.ErrNotExist }, ctx: fsRegistrationContext{ isWindows: false, diskIoCounters: map[string]disk.IOCountersStat{ "sda1": {Name: "sda1"}, "archive": {Name: "archive"}, }, }, } discovery.addConfiguredExtraFilesystems("sda1__fast,/srv/archive__cold") assert.Contains(t, agent.fsStats, "sda1") assert.Equal(t, "fast", agent.fsStats["sda1"].Name) assert.Contains(t, agent.fsStats, "archive") assert.Equal(t, "cold", agent.fsStats["archive"].Name) }) } func TestAddExtraFilesystemFolders(t *testing.T) { t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) { agent := &Agent{fsStats: map[string]*system.FsStats{ "existing": {Mountpoint: "/extra-filesystems/existing"}, }} discovery := diskDiscovery{ agent: agent, ctx: fsRegistrationContext{ isWindows: false, efPath: "/extra-filesystems", diskIoCounters: map[string]disk.IOCountersStat{ "newdisk": {Name: "newdisk"}, }, }, } discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"}) assert.Len(t, agent.fsStats, 2) stats, exists := agent.fsStats["newdisk"] assert.True(t, exists) assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint) assert.Equal(t, "Archive", stats.Name) }) } func TestFindIoDevice(t *testing.T) { t.Run("matches by device name", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sda": {Name: "sda"}, "sdb": {Name: "sdb"}, } device, ok := findIoDevice("sdb", ioCounters) assert.True(t, ok) assert.Equal(t, "sdb", device) }) t.Run("matches by device label", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sda": {Name: "sda", Label: "rootfs"}, "sdb": {Name: "sdb"}, } device, ok := findIoDevice("rootfs", ioCounters) assert.True(t, ok) assert.Equal(t, "sda", device) }) t.Run("returns no match when not found", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sda": {Name: "sda"}, "sdb": {Name: "sdb"}, } device, ok := findIoDevice("nvme0n1p1", ioCounters) assert.False(t, ok) assert.Equal(t, "", device) }) t.Run("uses uncertain unique prefix fallback", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "nvme0n1": {Name: "nvme0n1"}, "sda": {Name: "sda"}, } device, ok := findIoDevice("nvme0n1p2", ioCounters) assert.True(t, ok) assert.Equal(t, "nvme0n1", device) }) t.Run("uses dominant activity when prefix matches are ambiguous", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sda": {Name: "sda", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100}, "sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50}, } device, ok := findIoDevice("sd", ioCounters) assert.True(t, ok) assert.Equal(t, "sda", device) }) t.Run("uses highest activity when ambiguous without dominance", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sda": {Name: "sda", ReadBytes: 3000, WriteBytes: 3000, ReadCount: 50, WriteCount: 50}, "sdb": {Name: "sdb", ReadBytes: 2500, WriteBytes: 2500, ReadCount: 40, WriteCount: 40}, } device, ok := findIoDevice("sd", ioCounters) assert.True(t, ok) assert.Equal(t, "sda", device) }) t.Run("matches /dev/-prefixed partition to parent disk", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "nda0": {Name: "nda0", ReadBytes: 1000, WriteBytes: 1000}, } device, ok := findIoDevice("/dev/nda0p2", ioCounters) assert.True(t, ok) assert.Equal(t, "nda0", device) }) t.Run("uses deterministic name tie-breaker", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sdb": {Name: "sdb", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10}, "sda": {Name: "sda", ReadBytes: 2000, WriteBytes: 2000, ReadCount: 10, WriteCount: 10}, } device, ok := findIoDevice("sd", ioCounters) assert.True(t, ok) assert.Equal(t, "sda", device) }) } func TestFilesystemMatchesPartitionSetting(t *testing.T) { p := disk.PartitionStat{Device: "/dev/ada0p2", Mountpoint: "/"} t.Run("matches mountpoint setting", func(t *testing.T) { assert.True(t, filesystemMatchesPartitionSetting("/", p)) }) t.Run("matches exact partition setting", func(t *testing.T) { assert.True(t, filesystemMatchesPartitionSetting("ada0p2", p)) assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0p2", p)) }) t.Run("matches prefix-style parent setting", func(t *testing.T) { assert.True(t, filesystemMatchesPartitionSetting("ada0", p)) assert.True(t, filesystemMatchesPartitionSetting("/dev/ada0", p)) }) t.Run("does not match unrelated device", func(t *testing.T) { assert.False(t, filesystemMatchesPartitionSetting("sda", p)) assert.False(t, filesystemMatchesPartitionSetting("nvme0n1", p)) assert.False(t, filesystemMatchesPartitionSetting("", p)) }) } func TestMostActiveIoDevice(t *testing.T) { t.Run("returns most active device", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "nda0": {Name: "nda0", ReadBytes: 5000, WriteBytes: 5000, ReadCount: 100, WriteCount: 100}, "nda1": {Name: "nda1", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 50, WriteCount: 50}, } assert.Equal(t, "nda0", mostActiveIoDevice(ioCounters)) }) t.Run("uses deterministic tie-breaker", func(t *testing.T) { ioCounters := map[string]disk.IOCountersStat{ "sdb": {Name: "sdb", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10}, "sda": {Name: "sda", ReadBytes: 1000, WriteBytes: 1000, ReadCount: 10, WriteCount: 10}, } assert.Equal(t, "sda", mostActiveIoDevice(ioCounters)) }) t.Run("returns empty for empty map", func(t *testing.T) { assert.Equal(t, "", mostActiveIoDevice(map[string]disk.IOCountersStat{})) }) } func TestIsDockerSpecialMountpoint(t *testing.T) { testCases := []struct { name string mountpoint string expected bool }{ {name: "hosts", mountpoint: "/etc/hosts", expected: true}, {name: "resolv", mountpoint: "/etc/resolv.conf", expected: true}, {name: "hostname", mountpoint: "/etc/hostname", expected: true}, {name: "root", mountpoint: "/", expected: false}, {name: "passwd", mountpoint: "/etc/passwd", expected: false}, {name: "extra-filesystem", mountpoint: "/extra-filesystems/sda1", expected: false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { assert.Equal(t, tc.expected, isDockerSpecialMountpoint(tc.mountpoint)) }) } } func TestInitializeDiskInfoWithCustomNames(t *testing.T) { // Set up environment variables oldEnv := os.Getenv("EXTRA_FILESYSTEMS") defer func() { if oldEnv != "" { os.Setenv("EXTRA_FILESYSTEMS", oldEnv) } else { os.Unsetenv("EXTRA_FILESYSTEMS") } }() // Test with custom names os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2") // Mock disk partitions (we'll just test the parsing logic) // Since the actual disk operations are system-dependent, we'll focus on the parsing testCases := []struct { envValue string expectedFs []string expectedNames map[string]string }{ { envValue: "sda1__my-storage,sdb1__backup-drive", expectedFs: []string{"sda1", "sdb1"}, expectedNames: map[string]string{ "sda1": "my-storage", "sdb1": "backup-drive", }, }, { envValue: "sda1,nvme0n1p2__fast-ssd", expectedFs: []string{"sda1", "nvme0n1p2"}, expectedNames: map[string]string{ "nvme0n1p2": "fast-ssd", }, }, } for _, tc := range testCases { t.Run("env_"+tc.envValue, func(t *testing.T) { os.Setenv("EXTRA_FILESYSTEMS", tc.envValue) // Create mock partitions that would match our test cases partitions := []disk.PartitionStat{} for _, fs := range tc.expectedFs { if strings.HasPrefix(fs, "/dev/") { partitions = append(partitions, disk.PartitionStat{ Device: fs, Mountpoint: fs, }) } else { partitions = append(partitions, disk.PartitionStat{ Device: "/dev/" + fs, Mountpoint: "/" + fs, }) } } // Test the parsing logic by calling the relevant part // We'll create a simplified version to test just the parsing extraFilesystems := tc.envValue for fsEntry := range strings.SplitSeq(extraFilesystems, ",") { // Parse the entry fsEntry = strings.TrimSpace(fsEntry) var fs, customName string if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 { fs = strings.TrimSpace(parts[0]) customName = strings.TrimSpace(parts[1]) } else { fs = fsEntry } // Verify the device is in our expected list assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list") // Check if custom name should exist if expectedName, exists := tc.expectedNames[fs]; exists { assert.Equal(t, expectedName, customName, "custom name should match expected") } else { assert.Empty(t, customName, "custom name should be empty when not expected") } } }) } } func TestFsStatsWithCustomNames(t *testing.T) { // Test that FsStats properly stores custom names fsStats := &system.FsStats{ Mountpoint: "/mnt/storage", Name: "my-custom-storage", DiskTotal: 100.0, DiskUsed: 50.0, } assert.Equal(t, "my-custom-storage", fsStats.Name) assert.Equal(t, "/mnt/storage", fsStats.Mountpoint) assert.Equal(t, 100.0, fsStats.DiskTotal) assert.Equal(t, 50.0, fsStats.DiskUsed) } func TestExtraFsKeyGeneration(t *testing.T) { // Test the logic for generating ExtraFs keys with custom names testCases := []struct { name string deviceName string customName string expectedKey string }{ { name: "with custom name", deviceName: "sda1", customName: "my-storage", expectedKey: "my-storage", }, { name: "without custom name", deviceName: "sda1", customName: "", expectedKey: "sda1", }, { name: "empty custom name falls back to device", deviceName: "nvme0n1p2", customName: "", expectedKey: "nvme0n1p2", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Simulate the key generation logic from agent.go key := tc.deviceName if tc.customName != "" { key = tc.customName } assert.Equal(t, tc.expectedKey, key) }) } } func TestDiskUsageCaching(t *testing.T) { t.Run("caching disabled updates all filesystems", func(t *testing.T) { agent := &Agent{ fsStats: map[string]*system.FsStats{ "sda": {Root: true, Mountpoint: "/"}, "sdb": {Root: false, Mountpoint: "/mnt/storage"}, }, diskUsageCacheDuration: 0, // caching disabled } var stats system.Stats agent.updateDiskUsage(&stats) // Both should be updated (non-zero values from disk.Usage) // Root stats should be populated in systemStats assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(), "lastDiskUsageUpdate should be set when caching is disabled") }) t.Run("caching enabled always updates root filesystem", func(t *testing.T) { agent := &Agent{ fsStats: map[string]*system.FsStats{ "sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50}, "sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100}, }, diskUsageCacheDuration: 1 * time.Hour, lastDiskUsageUpdate: time.Now(), // cache is fresh } // Store original extra fs values originalExtraTotal := agent.fsStats["sdb"].DiskTotal originalExtraUsed := agent.fsStats["sdb"].DiskUsed var stats system.Stats agent.updateDiskUsage(&stats) // Root should be updated (systemStats populated from disk.Usage call) // We can't easily check if disk.Usage was called, but we verify the flow works // Extra filesystem should retain cached values (not reset) assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal, "extra filesystem DiskTotal should be unchanged when cached") assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed, "extra filesystem DiskUsed should be unchanged when cached") }) t.Run("first call always updates all filesystems", func(t *testing.T) { agent := &Agent{ fsStats: map[string]*system.FsStats{ "sda": {Root: true, Mountpoint: "/"}, "sdb": {Root: false, Mountpoint: "/mnt/storage"}, }, diskUsageCacheDuration: 1 * time.Hour, // lastDiskUsageUpdate is zero (first call) } var stats system.Stats agent.updateDiskUsage(&stats) // After first call, lastDiskUsageUpdate should be set assert.False(t, agent.lastDiskUsageUpdate.IsZero(), "lastDiskUsageUpdate should be set after first call") }) t.Run("expired cache updates extra filesystems", func(t *testing.T) { agent := &Agent{ fsStats: map[string]*system.FsStats{ "sda": {Root: true, Mountpoint: "/"}, "sdb": {Root: false, Mountpoint: "/mnt/storage"}, }, diskUsageCacheDuration: 1 * time.Millisecond, lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired } var stats system.Stats agent.updateDiskUsage(&stats) // lastDiskUsageUpdate should be refreshed since cache expired assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second, "lastDiskUsageUpdate should be refreshed when cache expires") }) } func TestHasSameDiskUsage(t *testing.T) { const toleranceBytes uint64 = 16 * 1024 * 1024 t.Run("returns true when totals and usage are equal", func(t *testing.T) { a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024} b := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024} assert.True(t, hasSameDiskUsage(a, b)) }) t.Run("returns true within tolerance", func(t *testing.T) { a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024} b := &disk.UsageStat{ Total: a.Total + toleranceBytes - 1, Used: a.Used - toleranceBytes + 1, } assert.True(t, hasSameDiskUsage(a, b)) }) t.Run("returns false when total exceeds tolerance", func(t *testing.T) { a := &disk.UsageStat{Total: 100 * 1024 * 1024 * 1024, Used: 42 * 1024 * 1024 * 1024} b := &disk.UsageStat{ Total: a.Total + toleranceBytes + 1, Used: a.Used, } assert.False(t, hasSameDiskUsage(a, b)) }) t.Run("returns false for nil or zero total", func(t *testing.T) { assert.False(t, hasSameDiskUsage(nil, &disk.UsageStat{Total: 1, Used: 1})) assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 1, Used: 1}, nil)) assert.False(t, hasSameDiskUsage(&disk.UsageStat{Total: 0, Used: 0}, &disk.UsageStat{Total: 1, Used: 1})) }) } func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) { agent := &Agent{ fsStats: map[string]*system.FsStats{ "sda": {}, "sdb": {}, }, fsNames: []string{"stale", "sda"}, } agent.initializeDiskIoStats(map[string]disk.IOCountersStat{ "sda": {Name: "sda", ReadBytes: 10, WriteBytes: 20}, "sdb": {Name: "sdb", ReadBytes: 30, WriteBytes: 40}, }) assert.ElementsMatch(t, []string{"sda", "sdb"}, agent.fsNames) assert.Len(t, agent.fsNames, 2) assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead) assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite) assert.False(t, agent.fsStats["sda"].Time.IsZero()) assert.False(t, agent.fsStats["sdb"].Time.IsZero()) agent.initializeDiskIoStats(map[string]disk.IOCountersStat{ "sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60}, }) assert.Equal(t, []string{"sdb"}, agent.fsNames) assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead) assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite) } ================================================ FILE: agent/docker.go ================================================ package agent import ( "bufio" "bytes" "context" "encoding/binary" "encoding/json" "errors" "fmt" "io" "log/slog" "net" "net/http" "net/url" "os" "path" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/container" "github.com/blang/semver" ) // ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.) // This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`) var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`) const ( // Docker API timeout in milliseconds dockerTimeoutMs = 2100 // Maximum realistic network speed (5 GB/s) to detect bad deltas maxNetworkSpeedBps uint64 = 5e9 // Maximum conceivable memory usage of a container (100TB) to detect bad memory stats maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024 // Number of log lines to request when fetching container logs dockerLogsTail = 200 // Maximum size of a single log frame (1MB) to prevent memory exhaustion // A single log line larger than 1MB is likely an error or misconfiguration maxLogFrameSize = 1024 * 1024 // Maximum total log content size (5MB) to prevent memory exhaustion // This provides a reasonable limit for network transfer and browser rendering maxTotalLogSize = 5 * 1024 * 1024 ) type dockerManager struct { client *http.Client // Client to query Docker API wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish sem chan struct{} // Semaphore to limit concurrent container requests containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap apiContainerList []*container.ApiInfo // List of containers from Docker API 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 buf *bytes.Buffer // Buffer to store and read response bodies decoder *json.Decoder // Reusable JSON decoder that reads from buf apiStats *container.ApiStats // Reusable API stats object excludeContainers []string // Patterns to exclude containers by name usingPodman bool // Whether the Docker Engine API is running on Podman // Cache-time-aware tracking for CPU stats (similar to cpu.go) // Maps cache time intervals to container-specific CPU usage tracking lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage lastCpuSystem map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu system usage lastCpuReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last read time (Windows) // Network delta trackers - one per cache time to avoid interference // cacheTimeMs -> DeltaTracker for network bytes sent/received networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] retrySleep func(time.Duration) } // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests type userAgentRoundTripper struct { rt http.RoundTripper userAgent string } // RoundTrip implements the http.RoundTripper interface func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", u.userAgent) return u.rt.RoundTrip(req) } // Add goroutine to the queue func (d *dockerManager) queue() { d.wg.Add(1) if d.goodDockerVersion { d.sem <- struct{}{} } } // Remove goroutine from the queue func (d *dockerManager) dequeue() { d.wg.Done() if d.goodDockerVersion { <-d.sem } } // shouldExcludeContainer checks if a container name matches any exclusion pattern func (dm *dockerManager) shouldExcludeContainer(name string) bool { if len(dm.excludeContainers) == 0 { return false } for _, pattern := range dm.excludeContainers { if match, _ := path.Match(pattern, name); match { return true } } return false } // Returns stats for all running containers with cache-time-aware delta tracking func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) { resp, err := dm.client.Get("http://localhost/containers/json") if err != nil { return nil, err } dm.apiContainerList = dm.apiContainerList[:0] if err := dm.decode(resp, &dm.apiContainerList); err != nil { 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 if dm.validIds == nil { dm.validIds = make(map[string]struct{}, containersLength) } else { clear(dm.validIds) } var failedContainers []*container.ApiInfo for _, ctr := range dm.apiContainerList { ctr.IdShort = ctr.Id[:12] // Skip this container if it matches the exclusion pattern if dm.shouldExcludeContainer(ctr.Names[0][1:]) { slog.Debug("Excluding container", "name", ctr.Names[0][1:]) continue } dm.validIds[ctr.IdShort] = struct{}{} // check if container is less than 1 minute old (possible restart) // note: can't use Created field because it's not updated on restart if strings.Contains(ctr.Status, "second") { // if so, remove old container data dm.deleteContainerStatsSync(ctr.IdShort) } dm.queue() go func(ctr *container.ApiInfo) { defer dm.dequeue() err := dm.updateContainerStats(ctr, cacheTimeMs) // if error, delete from map and add to failed list to retry if err != nil { dm.containerStatsMutex.Lock() delete(dm.containerStatsMap, ctr.IdShort) failedContainers = append(failedContainers, ctr) dm.containerStatsMutex.Unlock() } }(ctr) } dm.wg.Wait() // retry failed containers separately so we can run them in parallel (docker 24 bug) if len(failedContainers) > 0 { slog.Debug("Retrying failed containers", "count", len(failedContainers)) for i := range failedContainers { ctr := failedContainers[i] dm.queue() go func(ctr *container.ApiInfo) { defer dm.dequeue() if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil { slog.Error("Error getting container stats", "err", err2) } }(ctr) } dm.wg.Wait() } // populate final stats and remove old / invalid container stats stats := make([]*container.Stats, 0, containersLength) for id, v := range dm.containerStatsMap { if _, exists := dm.validIds[id]; !exists { delete(dm.containerStatsMap, id) } else { stats = append(stats, v) } } // prepare network trackers for next interval for this cache time dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) return stats, nil } // initializeCpuTracking initializes CPU tracking maps for a specific cache time interval func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) { // Initialize cache time maps if they don't exist if dm.lastCpuContainer[cacheTimeMs] == nil { dm.lastCpuContainer[cacheTimeMs] = make(map[string]uint64) } if dm.lastCpuSystem[cacheTimeMs] == nil { dm.lastCpuSystem[cacheTimeMs] = make(map[string]uint64) } // Ensure the outer map exists before indexing if dm.lastCpuReadTime == nil { dm.lastCpuReadTime = make(map[uint16]map[string]time.Time) } if dm.lastCpuReadTime[cacheTimeMs] == nil { dm.lastCpuReadTime[cacheTimeMs] = make(map[string]time.Time) } } // getCpuPreviousValues returns previous CPU values for a container and cache time interval func (dm *dockerManager) getCpuPreviousValues(cacheTimeMs uint16, containerId string) (uint64, uint64) { return dm.lastCpuContainer[cacheTimeMs][containerId], dm.lastCpuSystem[cacheTimeMs][containerId] } // setCpuCurrentValues stores current CPU values for a container and cache time interval func (dm *dockerManager) setCpuCurrentValues(cacheTimeMs uint16, containerId string, cpuContainer, cpuSystem uint64) { dm.lastCpuContainer[cacheTimeMs][containerId] = cpuContainer dm.lastCpuSystem[cacheTimeMs][containerId] = cpuSystem } // calculateMemoryUsage calculates memory usage from Docker API stats func calculateMemoryUsage(apiStats *container.ApiStats, isWindows bool) (uint64, error) { if isWindows { return apiStats.MemoryStats.PrivateWorkingSet, nil } memCache := apiStats.MemoryStats.Stats.InactiveFile if memCache == 0 { memCache = apiStats.MemoryStats.Stats.Cache } usedDelta := apiStats.MemoryStats.Usage - memCache if usedDelta <= 0 || usedDelta > maxMemoryUsage { return 0, fmt.Errorf("bad memory stats") } return usedDelta, nil } // getNetworkTracker returns the DeltaTracker for a specific cache time, creating it if needed func (dm *dockerManager) getNetworkTracker(cacheTimeMs uint16, isSent bool) *deltatracker.DeltaTracker[string, uint64] { var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64] if isSent { trackers = dm.networkSentTrackers } else { trackers = dm.networkRecvTrackers } if trackers[cacheTimeMs] == nil { trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]() } return trackers[cacheTimeMs] } // cycleNetworkDeltasForCacheTime cycles the network delta trackers for a specific cache time func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) { if dm.networkSentTrackers[cacheTimeMs] != nil { dm.networkSentTrackers[cacheTimeMs].Cycle() } if dm.networkRecvTrackers[cacheTimeMs] != nil { dm.networkRecvTrackers[cacheTimeMs].Cycle() } } // calculateNetworkStats calculates network sent/receive deltas using DeltaTracker func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) { var total_sent, total_recv uint64 for _, v := range apiStats.Networks { total_sent += v.TxBytes total_recv += v.RxBytes } // Get the DeltaTracker for this specific cache time sentTracker := dm.getNetworkTracker(cacheTimeMs, true) recvTracker := dm.getNetworkTracker(cacheTimeMs, false) // Set current values in the cache-time-specific DeltaTracker sentTracker.Set(ctr.IdShort, total_sent) recvTracker.Set(ctr.IdShort, total_recv) // Get deltas (bytes since last measurement) sent_delta_raw := sentTracker.Delta(ctr.IdShort) recv_delta_raw := recvTracker.Delta(ctr.IdShort) // Calculate bytes per second independently for Tx and Rx if we have previous data var sent_delta, recv_delta uint64 if initialized { millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds()) if millisecondsElapsed > 0 { if sent_delta_raw > 0 { sent_delta = sent_delta_raw * 1000 / millisecondsElapsed if sent_delta > maxNetworkSpeedBps { slog.Warn("Bad network delta", "container", name) sent_delta = 0 } } if recv_delta_raw > 0 { recv_delta = recv_delta_raw * 1000 / millisecondsElapsed if recv_delta > maxNetworkSpeedBps { slog.Warn("Bad network delta", "container", name) recv_delta = 0 } } } } return sent_delta, recv_delta } // validateCpuPercentage checks if CPU percentage is within valid range func validateCpuPercentage(cpuPct float64, containerName string) error { if cpuPct > 100 { return fmt.Errorf("%s cpu pct greater than 100: %+v", containerName, cpuPct) } return nil } // updateContainerStatsValues updates the final stats values func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) { stats.Cpu = utils.TwoDecimals(cpuPct) stats.Mem = utils.BytesToMegabytes(float64(usedMemory)) stats.Bandwidth = [2]uint64{sent_delta, recv_delta} // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3) stats.NetworkSent = utils.BytesToMegabytes(float64(sent_delta)) stats.NetworkRecv = utils.BytesToMegabytes(float64(recv_delta)) stats.PrevReadTime = readTime } // convertContainerPortsToString formats the ports of a container into a sorted, deduplicated string. // ctr.Ports is nilled out after processing so the slice is not accidentally reused. func convertContainerPortsToString(ctr *container.ApiInfo) string { if len(ctr.Ports) == 0 { return "" } sort.Slice(ctr.Ports, func(i, j int) bool { return ctr.Ports[i].PublicPort < ctr.Ports[j].PublicPort }) var builder strings.Builder seenPorts := make(map[uint16]struct{}) for _, p := range ctr.Ports { _, ok := seenPorts[p.PublicPort] if p.PublicPort == 0 || ok { continue } seenPorts[p.PublicPort] = struct{}{} if builder.Len() > 0 { builder.WriteString(", ") } switch p.IP { case "0.0.0.0", "::": default: builder.WriteString(p.IP) builder.WriteByte(':') } builder.WriteString(strconv.Itoa(int(p.PublicPort))) } // clear ports slice so it doesn't get reused and blend into next response ctr.Ports = nil return builder.String() } func parseDockerStatus(status string) (string, container.DockerHealth) { trimmed := strings.TrimSpace(status) if trimmed == "" { return "", container.DockerHealthNone } // Remove "About " from status trimmed = strings.Replace(trimmed, "About ", "", 1) openIdx := strings.LastIndex(trimmed, "(") if openIdx == -1 || !strings.HasSuffix(trimmed, ")") { return trimmed, container.DockerHealthNone } statusText := strings.TrimSpace(trimmed[:openIdx]) if statusText == "" { statusText = trimmed } healthText := strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")) // Some Docker statuses include a "health:" prefix inside the parentheses. // Strip it so it maps correctly to the known health states. if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 { prefix := strings.ToLower(strings.TrimSpace(healthText[:colonIdx])) if prefix == "health" || prefix == "health status" { healthText = strings.TrimSpace(healthText[colonIdx+1:]) } } if health, ok := parseDockerHealthStatus(healthText); ok { return statusText, health } return trimmed, container.DockerHealthNone } // parseDockerHealthStatus maps Docker health status strings to container.DockerHealth values func parseDockerHealthStatus(status string) (container.DockerHealth, bool) { health, ok := container.DockerHealthStrings[strings.ToLower(strings.TrimSpace(status))] return health, ok } // getPodmanContainerHealth fetches container health status from the container inspect endpoint. // Used for Podman which doesn't provide health status in the /containers/json endpoint as of March 2026. // https://github.com/containers/podman/issues/27786 func (dm *dockerManager) getPodmanContainerHealth(containerID string) (container.DockerHealth, error) { resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/json", url.PathEscape(containerID))) if err != nil { return container.DockerHealthNone, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return container.DockerHealthNone, fmt.Errorf("container inspect request failed: %s", resp.Status) } var inspectInfo struct { State struct { Health struct { Status string } } } if err := json.NewDecoder(resp.Body).Decode(&inspectInfo); err != nil { return container.DockerHealthNone, err } if health, ok := parseDockerHealthStatus(inspectInfo.State.Health.Status); ok { return health, nil } return container.DockerHealthNone, nil } // Updates stats for individual container with cache-time-aware delta tracking func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error { name := ctr.Names[0][1:] resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort)) if err != nil { return err } statusText, health := parseDockerStatus(ctr.Status) // Docker exposes Health.Status on /containers/json in API 1.52+. // Podman currently requires falling back to the inspect endpoint as of March 2026. // https://github.com/containers/podman/issues/27786 if ctr.Health.Status != "" { if h, ok := parseDockerHealthStatus(ctr.Health.Status); ok { health = h } } else if dm.usingPodman { if podmanHealth, err := dm.getPodmanContainerHealth(ctr.IdShort); err == nil { health = podmanHealth } } dm.containerStatsMutex.Lock() defer dm.containerStatsMutex.Unlock() // add empty values if they doesn't exist in map stats, initialized := dm.containerStatsMap[ctr.IdShort] if !initialized { stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image} dm.containerStatsMap[ctr.IdShort] = stats } stats.Id = ctr.IdShort stats.Status = statusText stats.Health = health if len(ctr.Ports) > 0 { stats.Ports = convertContainerPortsToString(ctr) } // reset current stats stats.Cpu = 0 stats.Mem = 0 stats.Bandwidth = [2]uint64{0, 0} // TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3) stats.NetworkSent = 0 stats.NetworkRecv = 0 res := dm.apiStats res.Networks = nil if err := dm.decode(resp, res); err != nil { return err } // Initialize CPU tracking for this cache time interval dm.initializeCpuTracking(cacheTimeMs) // Get previous CPU values prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort) // Calculate CPU percentage based on platform var cpuPct float64 if dm.isWindows { prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead) } else { cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem) } // Calculate memory usage usedMemory, err := calculateMemoryUsage(res, dm.isWindows) if err != nil { return fmt.Errorf("%s - %w - see https://github.com/henrygd/beszel/issues/144", name, err) } // Store current CPU stats for next calculation currentCpuContainer := res.CPUStats.CPUUsage.TotalUsage currentCpuSystem := res.CPUStats.SystemUsage dm.setCpuCurrentValues(cacheTimeMs, ctr.IdShort, currentCpuContainer, currentCpuSystem) // Validate CPU percentage if err := validateCpuPercentage(cpuPct, name); err != nil { return err } // Calculate network stats using DeltaTracker sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs) // Store current network values for legacy compatibility var total_sent, total_recv uint64 for _, v := range res.Networks { total_sent += v.TxBytes total_recv += v.RxBytes } stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv // Update final stats values updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read) // store per-cache-time read time for Windows CPU percent calc dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read return nil } // Delete container stats from map using mutex func (dm *dockerManager) deleteContainerStatsSync(id string) { dm.containerStatsMutex.Lock() defer dm.containerStatsMutex.Unlock() delete(dm.containerStatsMap, id) for ct := range dm.lastCpuContainer { delete(dm.lastCpuContainer[ct], id) } for ct := range dm.lastCpuSystem { delete(dm.lastCpuSystem[ct], id) } for ct := range dm.lastCpuReadTime { delete(dm.lastCpuReadTime[ct], id) } } // Creates a new http client for Docker or Podman API func newDockerManager() *dockerManager { dockerHost, exists := utils.GetEnv("DOCKER_HOST") if exists { // return nil if set to empty string if dockerHost == "" { return nil } } else { dockerHost = getDockerHost() } parsedURL, err := url.Parse(dockerHost) if err != nil { os.Exit(1) } transport := &http.Transport{ DisableCompression: true, MaxConnsPerHost: 0, } switch parsedURL.Scheme { case "unix": transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", parsedURL.Path) } case "tcp", "http", "https": transport.DialContext = func(ctx context.Context, proto, addr string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "tcp", parsedURL.Host) } default: slog.Error("Invalid DOCKER_HOST", "scheme", parsedURL.Scheme) os.Exit(1) } // configurable timeout timeout := time.Millisecond * time.Duration(dockerTimeoutMs) if t, set := utils.GetEnv("DOCKER_TIMEOUT"); set { timeout, err = time.ParseDuration(t) if err != nil { slog.Error(err.Error()) os.Exit(1) } slog.Info("DOCKER_TIMEOUT", "timeout", timeout) } // Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575 userAgentTransport := &userAgentRoundTripper{ rt: transport, userAgent: "Docker-Client/", } // Read container exclusion patterns from environment variable var excludeContainers []string if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" { parts := strings.SplitSeq(excludeStr, ",") for part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { excludeContainers = append(excludeContainers, trimmed) } } slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers) } manager := &dockerManager{ client: &http.Client{ Timeout: timeout, Transport: userAgentTransport, }, containerStatsMap: make(map[string]*container.Stats), sem: make(chan struct{}, 5), apiContainerList: []*container.ApiInfo{}, apiStats: &container.ApiStats{}, excludeContainers: excludeContainers, // Initialize cache-time-aware tracking structures lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), retrySleep: time.Sleep, } // If using podman, return client if strings.Contains(dockerHost, "podman") { manager.usingPodman = true manager.goodDockerVersion = true return manager } // run version check in goroutine to avoid blocking (server may not be ready and requires retries) go manager.checkDockerVersion() // give version check a chance to complete before returning time.Sleep(50 * time.Millisecond) return manager } // checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0. // Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch. func (dm *dockerManager) checkDockerVersion() { var err error var resp *http.Response var versionInfo struct { Version string `json:"Version"` } const versionMaxTries = 2 for i := 1; i <= versionMaxTries; i++ { resp, err = dm.client.Get("http://localhost/version") if err == nil && resp.StatusCode == http.StatusOK { break } if resp != nil { resp.Body.Close() } if i < versionMaxTries { slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp) dm.retrySleep(5 * time.Second) } } if err != nil || resp.StatusCode != http.StatusOK { return } if err := dm.decode(resp, &versionInfo); err != nil { return } // if version > 24, one-shot works correctly and we can limit concurrent operations if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { dm.goodDockerVersion = true } else { slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version)) } } // Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe. func (dm *dockerManager) decode(resp *http.Response, d any) error { if dm.buf == nil { // initialize buffer with 256kb starting size dm.buf = bytes.NewBuffer(make([]byte, 0, 1024*256)) dm.decoder = json.NewDecoder(dm.buf) } defer resp.Body.Close() defer dm.buf.Reset() _, err := dm.buf.ReadFrom(resp.Body) if err != nil { return err } return dm.decoder.Decode(d) } // Test docker / podman sockets and return if one exists func getDockerHost() string { scheme := "unix://" socks := []string{"/var/run/docker.sock", fmt.Sprintf("/run/user/%v/podman/podman.sock", os.Getuid())} for _, sock := range socks { if _, err := os.Stat(sock); err == nil { return scheme + sock } } return scheme + socks[0] } func validateContainerID(containerID string) error { if !dockerContainerIDPattern.MatchString(containerID) { return fmt.Errorf("invalid container id") } return nil } func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) { if err := validateContainerID(containerID); err != nil { return "", err } u := &url.URL{ Scheme: "http", Host: "localhost", Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action), } if len(query) > 0 { u.RawQuery = query.Encode() } return u.String(), nil } // getContainerInfo fetches the inspection data for a container func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) { endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := dm.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return nil, fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } // Remove sensitive environment variables from Config.Env var containerInfo map[string]any if err := json.NewDecoder(resp.Body).Decode(&containerInfo); err != nil { return nil, err } if config, ok := containerInfo["Config"].(map[string]any); ok { delete(config, "Env") } return json.Marshal(containerInfo) } // getLogs fetches the logs for a container func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { query := url.Values{ "stdout": []string{"1"}, "stderr": []string{"1"}, "tail": []string{fmt.Sprintf("%d", dockerLogsTail)}, } endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query) if err != nil { return "", err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err } resp, err := dm.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body))) } var builder strings.Builder contentType := resp.Header.Get("Content-Type") multiplexed := strings.HasSuffix(contentType, "multiplexed-stream") logReader := io.Reader(resp.Body) if !multiplexed { // Podman may return multiplexed logs without Content-Type. Sniff the first frame header // with a small buffered reader only when the header check fails. bufferedReader := bufio.NewReaderSize(resp.Body, 8) multiplexed = detectDockerMultiplexedStream(bufferedReader) logReader = bufferedReader } if err := decodeDockerLogStream(logReader, &builder, multiplexed); err != nil { return "", err } // Strip ANSI escape sequences from logs for clean display in web UI logs := builder.String() if strings.Contains(logs, "\x1b") { logs = ansiEscapePattern.ReplaceAllString(logs, "") } return logs, nil } func detectDockerMultiplexedStream(reader *bufio.Reader) bool { const headerSize = 8 header, err := reader.Peek(headerSize) if err != nil { return false } if header[0] != 0x01 && header[0] != 0x02 { return false } // Docker's stream framing header reserves bytes 1-3 as zero. if header[1] != 0 || header[2] != 0 || header[3] != 0 { return false } frameLen := binary.BigEndian.Uint32(header[4:]) return frameLen <= maxLogFrameSize } func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error { if !multiplexed { _, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize)) return err } const headerSize = 8 var header [headerSize]byte totalBytesRead := 0 for { if _, err := io.ReadFull(reader, header[:]); err != nil { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return nil } return err } frameLen := binary.BigEndian.Uint32(header[4:]) if frameLen == 0 { continue } // Prevent memory exhaustion from excessively large frames if frameLen > maxLogFrameSize { return fmt.Errorf("log frame size (%d) exceeds maximum (%d)", frameLen, maxLogFrameSize) } // Check if reading this frame would exceed total log size limit if totalBytesRead+int(frameLen) > maxTotalLogSize { // Read and discard remaining data to avoid blocking _, _ = io.CopyN(io.Discard, reader, int64(frameLen)) slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize) return nil } n, err := io.CopyN(builder, reader, int64(frameLen)) if err != nil { if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { return nil } return err } totalBytesRead += int(n) } } // GetHostInfo fetches the system info from Docker func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) { resp, err := dm.client.Get("http://localhost/info") if err != nil { return info, err } defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return info, err } return info, nil } func (dm *dockerManager) IsPodman() bool { return dm.usingPodman } ================================================ FILE: agent/docker_test.go ================================================ //go:build testing package agent import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/container" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) var defaultCacheTimeMs = uint16(60_000) type recordingRoundTripper struct { statusCode int body string contentType string called bool lastPath string lastQuery map[string]string } type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { rt.called = true rt.lastPath = req.URL.EscapedPath() rt.lastQuery = map[string]string{} for key, values := range req.URL.Query() { if len(values) > 0 { rt.lastQuery[key] = values[0] } } resp := &http.Response{ StatusCode: rt.statusCode, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(rt.body)), Request: req, } if rt.contentType != "" { resp.Header.Set("Content-Type", rt.contentType) } return resp, nil } // cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) { // Clear the CPU tracking maps for this cache time interval if dm.lastCpuContainer[cacheTimeMs] != nil { clear(dm.lastCpuContainer[cacheTimeMs]) } if dm.lastCpuSystem[cacheTimeMs] != nil { clear(dm.lastCpuSystem[cacheTimeMs]) } } func TestCalculateMemoryUsage(t *testing.T) { tests := []struct { name string apiStats *container.ApiStats isWindows bool expected uint64 expectError bool }{ { name: "Linux with valid memory stats", apiStats: &container.ApiStats{ MemoryStats: container.MemoryStats{ Usage: 1048576, // 1MB Stats: container.MemoryStatsStats{ Cache: 524288, // 512KB InactiveFile: 262144, // 256KB }, }, }, isWindows: false, expected: 786432, // 1MB - 256KB (inactive_file takes precedence) = 768KB expectError: false, }, { name: "Linux with zero cache uses inactive_file", apiStats: &container.ApiStats{ MemoryStats: container.MemoryStats{ Usage: 1048576, // 1MB Stats: container.MemoryStatsStats{ Cache: 0, InactiveFile: 262144, // 256KB }, }, }, isWindows: false, expected: 786432, // 1MB - 256KB = 768KB expectError: false, }, { name: "Windows with valid memory stats", apiStats: &container.ApiStats{ MemoryStats: container.MemoryStats{ PrivateWorkingSet: 524288, // 512KB }, }, isWindows: true, expected: 524288, expectError: false, }, { name: "Linux with zero usage returns error", apiStats: &container.ApiStats{ MemoryStats: container.MemoryStats{ Usage: 0, Stats: container.MemoryStatsStats{ Cache: 0, InactiveFile: 0, }, }, }, isWindows: false, expected: 0, expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := calculateMemoryUsage(tt.apiStats, tt.isWindows) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, result) } }) } } func TestBuildDockerContainerEndpoint(t *testing.T) { t.Run("valid container ID builds escaped endpoint", func(t *testing.T) { endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil) require.NoError(t, err) assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint) }) t.Run("invalid container ID is rejected", func(t *testing.T) { _, err := buildDockerContainerEndpoint("../../version", "json", nil) require.Error(t, err) assert.Contains(t, err.Error(), "invalid container id") }) } func TestContainerDetailsRequestsValidateContainerID(t *testing.T) { rt := &recordingRoundTripper{ statusCode: 200, body: `{"Config":{"Env":["SECRET=1"]}}`, } dm := &dockerManager{ client: &http.Client{Transport: rt}, } _, err := dm.getContainerInfo(context.Background(), "../version") require.Error(t, err) assert.Contains(t, err.Error(), "invalid container id") assert.False(t, rt.called, "request should be rejected before dispatching to Docker API") } func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) { t.Run("container info uses container json endpoint", func(t *testing.T) { rt := &recordingRoundTripper{ statusCode: 200, body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`, } dm := &dockerManager{ client: &http.Client{Transport: rt}, } body, err := dm.getContainerInfo(context.Background(), "0123456789ab") require.NoError(t, err) assert.True(t, rt.called) assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath) assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed") }) t.Run("container logs uses expected endpoint and query params", func(t *testing.T) { rt := &recordingRoundTripper{ statusCode: 200, body: "line1\nline2\n", } dm := &dockerManager{ client: &http.Client{Transport: rt}, } logs, err := dm.getLogs(context.Background(), "abcdef123456") require.NoError(t, err) assert.True(t, rt.called) assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath) assert.Equal(t, "1", rt.lastQuery["stdout"]) assert.Equal(t, "1", rt.lastQuery["stderr"]) assert.Equal(t, "200", rt.lastQuery["tail"]) assert.Equal(t, "line1\nline2\n", logs) }) } func TestGetPodmanContainerHealth(t *testing.T) { called := false dm := &dockerManager{ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { called = true assert.Equal(t, "/containers/0123456789ab/json", req.URL.EscapedPath()) return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)), Request: req, }, nil })}, } health, err := dm.getPodmanContainerHealth("0123456789ab") require.NoError(t, err) assert.True(t, called) assert.Equal(t, container.DockerHealthHealthy, health) } func TestValidateCpuPercentage(t *testing.T) { tests := []struct { name string cpuPct float64 containerName string expectError bool expectedError string }{ { name: "valid CPU percentage", cpuPct: 50.5, containerName: "test-container", expectError: false, }, { name: "zero CPU percentage", cpuPct: 0.0, containerName: "test-container", expectError: false, }, { name: "CPU percentage over 100", cpuPct: 150.5, containerName: "test-container", expectError: true, expectedError: "test-container cpu pct greater than 100: 150.5", }, { name: "CPU percentage exactly 100", cpuPct: 100.0, containerName: "test-container", expectError: false, }, { name: "negative CPU percentage", cpuPct: -10.0, containerName: "test-container", expectError: false, // Function only checks for > 100, not negative }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateCpuPercentage(tt.cpuPct, tt.containerName) if tt.expectError { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedError) } else { assert.NoError(t, err) } }) } } func TestUpdateContainerStatsValues(t *testing.T) { stats := &container.Stats{ Name: "test-container", Cpu: 0.0, Mem: 0.0, NetworkSent: 0.0, NetworkRecv: 0.0, PrevReadTime: time.Time{}, } testTime := time.Now() updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime) // Check CPU percentage (should be rounded to 2 decimals) assert.Equal(t, 75.5, stats.Cpu) // Check memory (should be converted to MB: 1048576 bytes = 1 MB) assert.Equal(t, 1.0, stats.Mem) // Check bandwidth (raw bytes) assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth) // Deprecated fields still populated for backward compatibility with older hubs assert.Equal(t, 0.5, stats.NetworkSent) // 524288 bytes = 0.5 MB assert.Equal(t, 0.25, stats.NetworkRecv) // 262144 bytes = 0.25 MB // Check read time assert.Equal(t, testTime, stats.PrevReadTime) } func TestInitializeCpuTracking(t *testing.T) { dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), } cacheTimeMs := uint16(30000) // Test initializing a new cache time dm.initializeCpuTracking(cacheTimeMs) // Check that maps were created assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs]) assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs]) assert.NotNil(t, dm.lastCpuReadTime[cacheTimeMs]) assert.Empty(t, dm.lastCpuContainer[cacheTimeMs]) assert.Empty(t, dm.lastCpuSystem[cacheTimeMs]) // Test initializing existing cache time (should not overwrite) dm.lastCpuContainer[cacheTimeMs]["test"] = 100 dm.lastCpuSystem[cacheTimeMs]["test"] = 200 dm.initializeCpuTracking(cacheTimeMs) // Should still have the existing values assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["test"]) assert.Equal(t, uint64(200), dm.lastCpuSystem[cacheTimeMs]["test"]) } func TestGetCpuPreviousValues(t *testing.T) { dm := &dockerManager{ lastCpuContainer: map[uint16]map[string]uint64{ 30000: {"container1": 100, "container2": 200}, }, lastCpuSystem: map[uint16]map[string]uint64{ 30000: {"container1": 150, "container2": 250}, }, } // Test getting existing values container, system := dm.getCpuPreviousValues(30000, "container1") assert.Equal(t, uint64(100), container) assert.Equal(t, uint64(150), system) // Test getting non-existing container container, system = dm.getCpuPreviousValues(30000, "nonexistent") assert.Equal(t, uint64(0), container) assert.Equal(t, uint64(0), system) // Test getting non-existing cache time container, system = dm.getCpuPreviousValues(60000, "container1") assert.Equal(t, uint64(0), container) assert.Equal(t, uint64(0), system) } func TestSetCpuCurrentValues(t *testing.T) { dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), } cacheTimeMs := uint16(30000) containerId := "test-container" // Initialize the cache time maps first dm.initializeCpuTracking(cacheTimeMs) // Set values dm.setCpuCurrentValues(cacheTimeMs, containerId, 500, 750) // Check that values were set assert.Equal(t, uint64(500), dm.lastCpuContainer[cacheTimeMs][containerId]) assert.Equal(t, uint64(750), dm.lastCpuSystem[cacheTimeMs][containerId]) } func TestCalculateNetworkStats(t *testing.T) { // Create docker manager with tracker maps dm := &dockerManager{ networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } cacheTimeMs := uint16(30000) // Pre-populate tracker for this cache time with initial values sentTracker := deltatracker.NewDeltaTracker[string, uint64]() recvTracker := deltatracker.NewDeltaTracker[string, uint64]() sentTracker.Set("container1", 1000) recvTracker.Set("container1", 800) sentTracker.Cycle() // Move to previous recvTracker.Cycle() dm.networkSentTrackers[cacheTimeMs] = sentTracker dm.networkRecvTrackers[cacheTimeMs] = recvTracker ctr := &container.ApiInfo{ IdShort: "container1", } apiStats := &container.ApiStats{ Networks: map[string]container.NetworkStats{ "eth0": {TxBytes: 2000, RxBytes: 1800}, // New values }, } stats := &container.Stats{ PrevReadTime: time.Now().Add(-time.Second), // 1 second ago } // Test with initialized container sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs) // Should return calculated byte rates per second assert.GreaterOrEqual(t, sent, uint64(0)) assert.GreaterOrEqual(t, recv, uint64(0)) // Cycle and test one-direction change (Tx only) is reflected independently dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs) assert.Greater(t, sent, uint64(0)) assert.Equal(t, uint64(0), recv) } func TestDockerManagerCreation(t *testing.T) { // Test that dockerManager can be created without panicking dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } assert.NotNil(t, dm) assert.NotNil(t, dm.lastCpuContainer) assert.NotNil(t, dm.lastCpuSystem) assert.NotNil(t, dm.networkSentTrackers) assert.NotNil(t, dm.networkRecvTrackers) } func TestCheckDockerVersion(t *testing.T) { tests := []struct { name string responses []struct { statusCode int body string } expectedGood bool expectedRequests int }{ { name: "200 with good version on first try", responses: []struct { statusCode int body string }{ {http.StatusOK, `{"Version":"25.0.1"}`}, }, expectedGood: true, expectedRequests: 1, }, { name: "200 with old version on first try", responses: []struct { statusCode int body string }{ {http.StatusOK, `{"Version":"24.0.7"}`}, }, expectedGood: false, expectedRequests: 1, }, { name: "non-200 then 200 with good version", responses: []struct { statusCode int body string }{ {http.StatusServiceUnavailable, `"not ready"`}, {http.StatusOK, `{"Version":"25.1.0"}`}, }, expectedGood: true, expectedRequests: 2, }, { name: "non-200 on all retries", responses: []struct { statusCode int body string }{ {http.StatusInternalServerError, `"error"`}, {http.StatusUnauthorized, `"error"`}, }, expectedGood: false, expectedRequests: 2, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { requestCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { idx := requestCount requestCount++ if idx >= len(tt.responses) { idx = len(tt.responses) - 1 } w.WriteHeader(tt.responses[idx].statusCode) fmt.Fprint(w, tt.responses[idx].body) })) defer server.Close() dm := &dockerManager{ client: &http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, network, _ string) (net.Conn, error) { return net.Dial(network, server.Listener.Addr().String()) }, }, }, retrySleep: func(time.Duration) {}, } dm.checkDockerVersion() assert.Equal(t, tt.expectedGood, dm.goodDockerVersion) assert.Equal(t, tt.expectedRequests, requestCount) }) } t.Run("request error on all retries", func(t *testing.T) { requestCount := 0 dm := &dockerManager{ client: &http.Client{ Transport: &http.Transport{ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { requestCount++ return nil, errors.New("connection refused") }, }, }, retrySleep: func(time.Duration) {}, } dm.checkDockerVersion() assert.False(t, dm.goodDockerVersion) assert.Equal(t, 2, requestCount) }) } func TestCycleCpuDeltas(t *testing.T) { dm := &dockerManager{ lastCpuContainer: map[uint16]map[string]uint64{ 30000: {"container1": 100, "container2": 200}, }, lastCpuSystem: map[uint16]map[string]uint64{ 30000: {"container1": 150, "container2": 250}, }, lastCpuReadTime: map[uint16]map[string]time.Time{ 30000: {"container1": time.Now()}, }, } cacheTimeMs := uint16(30000) // Verify values exist before cycling assert.Equal(t, uint64(100), dm.lastCpuContainer[cacheTimeMs]["container1"]) assert.Equal(t, uint64(200), dm.lastCpuContainer[cacheTimeMs]["container2"]) // Cycle the CPU deltas dm.cycleCpuDeltas(cacheTimeMs) // Verify values are cleared assert.Empty(t, dm.lastCpuContainer[cacheTimeMs]) assert.Empty(t, dm.lastCpuSystem[cacheTimeMs]) // lastCpuReadTime is not affected by cycleCpuDeltas assert.NotEmpty(t, dm.lastCpuReadTime[cacheTimeMs]) } func TestCycleNetworkDeltas(t *testing.T) { // Create docker manager with tracker maps dm := &dockerManager{ networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } cacheTimeMs := uint16(30000) // Get trackers for this cache time (creates them) sentTracker := dm.getNetworkTracker(cacheTimeMs, true) recvTracker := dm.getNetworkTracker(cacheTimeMs, false) // Set some test data sentTracker.Set("test", 100) recvTracker.Set("test", 200) // This should not panic assert.NotPanics(t, func() { dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) }) // Verify that cycle worked by checking deltas are now zero (no previous values) assert.Equal(t, uint64(0), sentTracker.Delta("test")) assert.Equal(t, uint64(0), recvTracker.Delta("test")) } func TestConstants(t *testing.T) { // Test that constants are properly defined assert.Equal(t, uint16(60000), defaultCacheTimeMs) assert.Equal(t, uint64(5e9), maxNetworkSpeedBps) assert.Equal(t, 2100, dockerTimeoutMs) } func TestDockerStatsWithMockData(t *testing.T) { // Create a docker manager with initialized tracking dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), containerStatsMap: make(map[string]*container.Stats), } cacheTimeMs := uint16(30000) // Test that initializeCpuTracking works dm.initializeCpuTracking(cacheTimeMs) assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs]) assert.NotNil(t, dm.lastCpuSystem[cacheTimeMs]) // Test that we can set and get CPU values dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 2000) container, system := dm.getCpuPreviousValues(cacheTimeMs, "test-container") assert.Equal(t, uint64(1000), container) assert.Equal(t, uint64(2000), system) } func TestMemoryStatsEdgeCases(t *testing.T) { tests := []struct { name string usage uint64 cache uint64 inactive uint64 isWindows bool expected uint64 hasError bool }{ {"Linux normal case", 1000, 200, 0, false, 800, false}, {"Linux with inactive file", 1000, 0, 300, false, 700, false}, {"Windows normal case", 0, 0, 0, true, 500, false}, {"Linux zero usage error", 0, 0, 0, false, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { apiStats := &container.ApiStats{ MemoryStats: container.MemoryStats{ Usage: tt.usage, Stats: container.MemoryStatsStats{ Cache: tt.cache, InactiveFile: tt.inactive, }, }, } if tt.isWindows { apiStats.MemoryStats.PrivateWorkingSet = tt.expected } result, err := calculateMemoryUsage(apiStats, tt.isWindows) if tt.hasError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, result) } }) } } func TestContainerStatsInitialization(t *testing.T) { stats := &container.Stats{Name: "test-container"} // Verify initial values assert.Equal(t, "test-container", stats.Name) assert.Equal(t, 0.0, stats.Cpu) assert.Equal(t, 0.0, stats.Mem) assert.Equal(t, 0.0, stats.NetworkSent) assert.Equal(t, 0.0, stats.NetworkRecv) assert.Equal(t, time.Time{}, stats.PrevReadTime) // Test updating values testTime := time.Now() updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime) assert.Equal(t, 45.67, stats.Cpu) assert.Equal(t, 2.0, stats.Mem) assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth) // Deprecated fields still populated for backward compatibility with older hubs assert.Equal(t, 1.0, stats.NetworkSent) // 1048576 bytes = 1 MB assert.Equal(t, 0.5, stats.NetworkRecv) // 524288 bytes = 0.5 MB assert.Equal(t, testTime, stats.PrevReadTime) } // Test with real Docker API test data func TestCalculateMemoryUsageWithRealData(t *testing.T) { // Load minimal container stats from test data data, err := os.ReadFile("test-data/container.json") require.NoError(t, err) var apiStats container.ApiStats err = json.Unmarshal(data, &apiStats) require.NoError(t, err) // Test memory calculation with real data usedMemory, err := calculateMemoryUsage(&apiStats, false) require.NoError(t, err) // From the real data: usage - inactive_file = 507400192 - 165130240 = 342269952 expected := uint64(507400192 - 165130240) assert.Equal(t, expected, usedMemory) } func TestCpuPercentageCalculationWithRealData(t *testing.T) { // Load minimal container stats from test data data1, err := os.ReadFile("test-data/container.json") require.NoError(t, err) data2, err := os.ReadFile("test-data/container2.json") require.NoError(t, err) var apiStats1, apiStats2 container.ApiStats err = json.Unmarshal(data1, &apiStats1) require.NoError(t, err) err = json.Unmarshal(data2, &apiStats2) require.NoError(t, err) // Calculate delta manually: 314891801000 - 312055276000 = 2836525000 // System delta: 1368474900000000 - 1366399830000000 = 2075070000000 // Expected %: (2836525000 / 2075070000000) * 100 ≈ 0.1367% expectedPct := float64(2836525000) / float64(2075070000000) * 100.0 actualPct := apiStats2.CalculateCpuPercentLinux(apiStats1.CPUStats.CPUUsage.TotalUsage, apiStats1.CPUStats.SystemUsage) assert.InDelta(t, expectedPct, actualPct, 0.01) } func TestNetworkStatsCalculationWithRealData(t *testing.T) { // Create synthetic test data to avoid timing issues apiStats1 := &container.ApiStats{ Networks: map[string]container.NetworkStats{ "eth0": {TxBytes: 1000000, RxBytes: 500000}, }, } apiStats2 := &container.ApiStats{ Networks: map[string]container.NetworkStats{ "eth0": {TxBytes: 3000000, RxBytes: 1500000}, // 2MB sent, 1MB received increase }, } // Create docker manager with tracker maps dm := &dockerManager{ networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } ctr := &container.ApiInfo{IdShort: "test-container"} cacheTimeMs := uint16(30000) // Test with 30 second cache // Use exact timing for deterministic results exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond) stats := &container.Stats{ PrevReadTime: exactly1000msAgo, } // First call sets baseline sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) assert.Equal(t, uint64(0), sent1) assert.Equal(t, uint64(0), recv1) // Cycle to establish baseline for this cache time dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) // Calculate expected results precisely deltaSent := uint64(2000000) // 3000000 - 1000000 deltaRecv := uint64(1000000) // 1500000 - 500000 expectedElapsedMs := uint64(1000) // Exactly 1000ms expectedSentRate := deltaSent * 1000 / expectedElapsedMs // Should be exactly 2000000 expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000 // Second call with changed data sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs) // Should be exactly the expected rates (no tolerance needed) assert.Equal(t, expectedSentRate, sent2) assert.Equal(t, expectedRecvRate, recv2) // Bad speed cap: set absurd delta over 1ms and expect 0 due to cap dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond) apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0} apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta _, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs) assert.Equal(t, uint64(0), sent3) assert.Equal(t, uint64(0), recv3) } func TestContainerStatsEndToEndWithRealData(t *testing.T) { // Load minimal container stats data, err := os.ReadFile("test-data/container.json") require.NoError(t, err) var apiStats container.ApiStats err = json.Unmarshal(data, &apiStats) require.NoError(t, err) // Create a docker manager with proper initialization dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), containerStatsMap: make(map[string]*container.Stats), } // Initialize CPU tracking cacheTimeMs := uint16(30000) dm.initializeCpuTracking(cacheTimeMs) // Create container info ctr := &container.ApiInfo{ IdShort: "abc123", } // Initialize container stats stats := &container.Stats{Name: "jellyfin"} dm.containerStatsMap[ctr.IdShort] = stats // Test individual components that we can verify usedMemory, memErr := calculateMemoryUsage(&apiStats, false) assert.NoError(t, memErr) assert.Greater(t, usedMemory, uint64(0)) // Test CPU percentage validation cpuPct := 85.5 err = validateCpuPercentage(cpuPct, "jellyfin") assert.NoError(t, err) err = validateCpuPercentage(150.0, "jellyfin") assert.Error(t, err) // Test stats value updates testStats := &container.Stats{} testTime := time.Now() updateContainerStatsValues(testStats, cpuPct, usedMemory, 1000000, 500000, testTime) assert.Equal(t, cpuPct, testStats.Cpu) assert.Equal(t, utils.BytesToMegabytes(float64(usedMemory)), testStats.Mem) assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth) // Deprecated fields still populated for backward compatibility with older hubs assert.Equal(t, utils.BytesToMegabytes(1000000), testStats.NetworkSent) assert.Equal(t, utils.BytesToMegabytes(500000), testStats.NetworkRecv) assert.Equal(t, testTime, testStats.PrevReadTime) } func TestGetLogsDetectsMultiplexedWithoutContentType(t *testing.T) { // Docker multiplexed frame: [stream][0,0,0][len(4 bytes BE)][payload] frame := []byte{ 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 'H', 'e', 'l', 'l', 'o', } rt := &recordingRoundTripper{ statusCode: 200, body: string(frame), // Intentionally omit content type to simulate Podman behavior. } dm := &dockerManager{ client: &http.Client{Transport: rt}, } logs, err := dm.getLogs(context.Background(), "abcdef123456") require.NoError(t, err) assert.Equal(t, "Hello", logs) } func TestGetLogsDoesNotMisclassifyRawStreamAsMultiplexed(t *testing.T) { // Starts with 0x01, but doesn't match Docker frame signature (reserved bytes aren't all zero). raw := []byte{0x01, 0x02, 0x03, 0x04, 'r', 'a', 'w'} rt := &recordingRoundTripper{ statusCode: 200, body: string(raw), } dm := &dockerManager{ client: &http.Client{Transport: rt}, } logs, err := dm.getLogs(context.Background(), "abcdef123456") require.NoError(t, err) assert.Equal(t, raw, []byte(logs)) } func TestEdgeCasesWithRealData(t *testing.T) { // Test with minimal container stats minimalStats := &container.ApiStats{ CPUStats: container.CPUStats{ CPUUsage: container.CPUUsage{TotalUsage: 1000}, SystemUsage: 50000, }, MemoryStats: container.MemoryStats{ Usage: 1000000, Stats: container.MemoryStatsStats{ Cache: 0, InactiveFile: 0, }, }, Networks: map[string]container.NetworkStats{ "eth0": {TxBytes: 1000, RxBytes: 500}, }, } // Test memory calculation with zero cache/inactive usedMemory, err := calculateMemoryUsage(minimalStats, false) assert.NoError(t, err) assert.Equal(t, uint64(1000000), usedMemory) // Should equal usage when no cache // Test CPU percentage calculation cpuPct := minimalStats.CalculateCpuPercentLinux(0, 0) // First run assert.Equal(t, 0.0, cpuPct) // Test with Windows data minimalStats.MemoryStats.PrivateWorkingSet = 800000 usedMemory, err = calculateMemoryUsage(minimalStats, true) assert.NoError(t, err) assert.Equal(t, uint64(800000), usedMemory) } func TestDockerStatsWorkflow(t *testing.T) { // Test the complete workflow that can be tested without HTTP calls dm := &dockerManager{ lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), containerStatsMap: make(map[string]*container.Stats), } cacheTimeMs := uint16(30000) // Test CPU tracking workflow dm.initializeCpuTracking(cacheTimeMs) assert.NotNil(t, dm.lastCpuContainer[cacheTimeMs]) // Test setting and getting CPU values dm.setCpuCurrentValues(cacheTimeMs, "test-container", 1000, 50000) containerVal, systemVal := dm.getCpuPreviousValues(cacheTimeMs, "test-container") assert.Equal(t, uint64(1000), containerVal) assert.Equal(t, uint64(50000), systemVal) // Test network tracking workflow (multi-interface summation) sentTracker := dm.getNetworkTracker(cacheTimeMs, true) recvTracker := dm.getNetworkTracker(cacheTimeMs, false) // Simulate two interfaces summed by setting combined totals sentTracker.Set("test-container", 1000+2000) recvTracker.Set("test-container", 500+700) deltaSent := sentTracker.Delta("test-container") deltaRecv := recvTracker.Delta("test-container") assert.Equal(t, uint64(0), deltaSent) // No previous value assert.Equal(t, uint64(0), deltaRecv) // Cycle and test again dm.cycleNetworkDeltasForCacheTime(cacheTimeMs) // Increase each interface total (combined totals go up by 1500 and 800) sentTracker.Set("test-container", (1000+2000)+1500) recvTracker.Set("test-container", (500+700)+800) deltaSent = sentTracker.Delta("test-container") deltaRecv = recvTracker.Delta("test-container") assert.Equal(t, uint64(1500), deltaSent) assert.Equal(t, uint64(800), deltaRecv) } func TestNetworkRateCalculationFormula(t *testing.T) { // Test the exact formula used in calculateNetworkStats testCases := []struct { name string deltaBytes uint64 elapsedMs uint64 expectedRate uint64 }{ {"1MB over 1 second", 1000000, 1000, 1000000}, {"2MB over 1 second", 2000000, 1000, 2000000}, {"1MB over 2 seconds", 1000000, 2000, 500000}, {"500KB over 500ms", 500000, 500, 1000000}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // This is the exact formula from calculateNetworkStats actualRate := tc.deltaBytes * 1000 / tc.elapsedMs assert.Equal(t, tc.expectedRate, actualRate, "Rate calculation should be exact: %d bytes * 1000 / %d ms = %d", tc.deltaBytes, tc.elapsedMs, tc.expectedRate) }) } } func TestGetHostInfo(t *testing.T) { data, err := os.ReadFile("test-data/system_info.json") require.NoError(t, err) var info container.HostInfo err = json.Unmarshal(data, &info) require.NoError(t, err) assert.Equal(t, "6.8.0-31-generic", info.KernelVersion) assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem) // assert.Equal(t, "24.04", info.OSVersion) // assert.Equal(t, "linux", info.OSType) // assert.Equal(t, "x86_64", info.Architecture) assert.EqualValues(t, 4, info.NCPU) assert.EqualValues(t, 2095882240, info.MemTotal) // assert.Equal(t, "27.0.1", info.ServerVersion) } func TestDeltaTrackerCacheTimeIsolation(t *testing.T) { // Test that different cache times have separate DeltaTracker instances dm := &dockerManager{ networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } ctr := &container.ApiInfo{IdShort: "web-server"} cacheTime1 := uint16(30000) cacheTime2 := uint16(60000) // Get trackers for different cache times (creates separate instances) sentTracker1 := dm.getNetworkTracker(cacheTime1, true) recvTracker1 := dm.getNetworkTracker(cacheTime1, false) sentTracker2 := dm.getNetworkTracker(cacheTime2, true) recvTracker2 := dm.getNetworkTracker(cacheTime2, false) // Verify they are different instances assert.NotSame(t, sentTracker1, sentTracker2) assert.NotSame(t, recvTracker1, recvTracker2) // Set values for cache time 1 sentTracker1.Set(ctr.IdShort, 1000000) recvTracker1.Set(ctr.IdShort, 500000) // Set values for cache time 2 sentTracker2.Set(ctr.IdShort, 2000000) recvTracker2.Set(ctr.IdShort, 1000000) // Verify they don't interfere (both should return 0 since no previous values) assert.Equal(t, uint64(0), sentTracker1.Delta(ctr.IdShort)) assert.Equal(t, uint64(0), recvTracker1.Delta(ctr.IdShort)) assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort)) assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort)) // Cycle cache time 1 trackers dm.cycleNetworkDeltasForCacheTime(cacheTime1) // Set new values for cache time 1 sentTracker1.Set(ctr.IdShort, 3000000) // 2MB increase recvTracker1.Set(ctr.IdShort, 1500000) // 1MB increase // Cache time 1 should show deltas, cache time 2 should still be 0 assert.Equal(t, uint64(2000000), sentTracker1.Delta(ctr.IdShort)) assert.Equal(t, uint64(1000000), recvTracker1.Delta(ctr.IdShort)) assert.Equal(t, uint64(0), sentTracker2.Delta(ctr.IdShort)) // Unaffected assert.Equal(t, uint64(0), recvTracker2.Delta(ctr.IdShort)) // Unaffected // Cycle cache time 2 and verify it works independently dm.cycleNetworkDeltasForCacheTime(cacheTime2) sentTracker2.Set(ctr.IdShort, 2500000) // 0.5MB increase recvTracker2.Set(ctr.IdShort, 1200000) // 0.2MB increase assert.Equal(t, uint64(500000), sentTracker2.Delta(ctr.IdShort)) assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort)) } func TestParseDockerStatus(t *testing.T) { tests := []struct { name string input string expectedStatus string expectedHealth container.DockerHealth }{ { name: "status with About an removed", input: "Up About an hour (healthy)", expectedStatus: "Up an hour", expectedHealth: container.DockerHealthHealthy, }, { name: "status without About an unchanged", input: "Up 2 hours (healthy)", expectedStatus: "Up 2 hours", expectedHealth: container.DockerHealthHealthy, }, { name: "status with About and no parentheses", input: "Up About an hour", expectedStatus: "Up an hour", expectedHealth: container.DockerHealthNone, }, { name: "status without parentheses", input: "Created", expectedStatus: "Created", expectedHealth: container.DockerHealthNone, }, { name: "empty status", input: "", expectedStatus: "", expectedHealth: container.DockerHealthNone, }, { name: "status health with health: prefix", input: "Up 5 minutes (health: starting)", expectedStatus: "Up 5 minutes", expectedHealth: container.DockerHealthStarting, }, { name: "status health with health status: prefix", input: "Up 10 minutes (health status: unhealthy)", expectedStatus: "Up 10 minutes", expectedHealth: container.DockerHealthUnhealthy, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { status, health := parseDockerStatus(tt.input) assert.Equal(t, tt.expectedStatus, status) assert.Equal(t, tt.expectedHealth, health) }) } } func TestParseDockerHealthStatus(t *testing.T) { tests := []struct { input string expectedHealth container.DockerHealth expectedOk bool }{ {"healthy", container.DockerHealthHealthy, true}, {"unhealthy", container.DockerHealthUnhealthy, true}, {"starting", container.DockerHealthStarting, true}, {"none", container.DockerHealthNone, true}, {" Healthy ", container.DockerHealthHealthy, true}, {"unknown", container.DockerHealthNone, false}, {"", container.DockerHealthNone, false}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { health, ok := parseDockerHealthStatus(tt.input) assert.Equal(t, tt.expectedHealth, health) assert.Equal(t, tt.expectedOk, ok) }) } } func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) { var requestedPaths []string dm := &dockerManager{ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { requestedPaths = append(requestedPaths, req.URL.EscapedPath()) switch req.URL.EscapedPath() { case "/containers/0123456789ab/stats": return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{ "read":"2026-03-15T21:26:59Z", "cpu_stats":{"cpu_usage":{"total_usage":1000},"system_cpu_usage":2000}, "memory_stats":{"usage":1048576,"stats":{"inactive_file":262144}}, "networks":{"eth0":{"rx_bytes":0,"tx_bytes":0}} }`)), Request: req, }, nil case "/containers/0123456789ab/json": return &http.Response{ StatusCode: http.StatusOK, Status: "200 OK", Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"State":{"Health":{"Status":"healthy"}}}`)), Request: req, }, nil default: return nil, fmt.Errorf("unexpected path: %s", req.URL.EscapedPath()) } })}, containerStatsMap: make(map[string]*container.Stats), apiStats: &container.ApiStats{}, usingPodman: true, lastCpuContainer: make(map[uint16]map[string]uint64), lastCpuSystem: make(map[uint16]map[string]uint64), lastCpuReadTime: make(map[uint16]map[string]time.Time), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } ctr := &container.ApiInfo{ IdShort: "0123456789ab", Names: []string{"/beszel"}, Status: "Up 2 minutes", Image: "beszel:latest", } err := dm.updateContainerStats(ctr, defaultCacheTimeMs) require.NoError(t, err) assert.Equal(t, []string{"/containers/0123456789ab/stats", "/containers/0123456789ab/json"}, requestedPaths) assert.Equal(t, container.DockerHealthHealthy, dm.containerStatsMap[ctr.IdShort].Health) assert.Equal(t, "Up 2 minutes", dm.containerStatsMap[ctr.IdShort].Status) } func TestConstantsAndUtilityFunctions(t *testing.T) { // Test constants are properly defined assert.Equal(t, uint16(60000), defaultCacheTimeMs) assert.Equal(t, uint64(5e9), maxNetworkSpeedBps) assert.Equal(t, 2100, dockerTimeoutMs) assert.Equal(t, uint32(1024*1024), uint32(maxLogFrameSize)) // 1MB assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB // Test utility functions assert.Equal(t, 1.5, utils.TwoDecimals(1.499)) assert.Equal(t, 1.5, utils.TwoDecimals(1.5)) assert.Equal(t, 1.5, utils.TwoDecimals(1.501)) assert.Equal(t, 1.0, utils.BytesToMegabytes(1048576)) // 1 MB assert.Equal(t, 0.5, utils.BytesToMegabytes(524288)) // 512 KB assert.Equal(t, 0.0, utils.BytesToMegabytes(0)) } func TestDecodeDockerLogStream(t *testing.T) { tests := []struct { name string input []byte expected string expectError bool multiplexed bool }{ { name: "simple log entry", input: []byte{ // Frame 1: stdout, 11 bytes 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B, 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd', }, expected: "Hello World", expectError: false, multiplexed: true, }, { name: "multiple frames", input: []byte{ // Frame 1: stdout, 5 bytes 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 'H', 'e', 'l', 'l', 'o', // Frame 2: stdout, 5 bytes 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 'W', 'o', 'r', 'l', 'd', }, expected: "HelloWorld", expectError: false, multiplexed: true, }, { name: "zero length frame", input: []byte{ // Frame 1: stdout, 0 bytes 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Frame 2: stdout, 5 bytes 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 'H', 'e', 'l', 'l', 'o', }, expected: "Hello", expectError: false, multiplexed: true, }, { name: "empty input", input: []byte{}, expected: "", expectError: false, multiplexed: true, }, { name: "raw stream (not multiplexed)", input: []byte("raw log content"), expected: "raw log content", multiplexed: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reader := bytes.NewReader(tt.input) var builder strings.Builder err := decodeDockerLogStream(reader, &builder, tt.multiplexed) if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.expected, builder.String()) } }) } } func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) { t.Run("excessively large frame should error", func(t *testing.T) { // Create a frame with size exceeding maxLogFrameSize excessiveSize := uint32(maxLogFrameSize + 1) input := []byte{ // Frame header with excessive size 0x01, 0x00, 0x00, 0x00, byte(excessiveSize >> 24), byte(excessiveSize >> 16), byte(excessiveSize >> 8), byte(excessiveSize), } reader := bytes.NewReader(input) var builder strings.Builder err := decodeDockerLogStream(reader, &builder, true) assert.Error(t, err) assert.Contains(t, err.Error(), "log frame size") assert.Contains(t, err.Error(), "exceeds maximum") }) t.Run("total size limit should truncate", func(t *testing.T) { // Create frames that exceed maxTotalLogSize (5MB) // Use frames within maxLogFrameSize (1MB) to avoid single-frame rejection frameSize := uint32(800 * 1024) // 800KB per frame var input []byte // Frames 1-6: 800KB each (total 4.8MB - within 5MB limit) for i := 0; i < 6; i++ { char := byte('A' + i) frameHeader := []byte{ 0x01, 0x00, 0x00, 0x00, byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize), } input = append(input, frameHeader...) input = append(input, bytes.Repeat([]byte{char}, int(frameSize))...) } // Frame 7: 800KB (would bring total to 5.6MB, exceeding 5MB limit - should be truncated) frame7Header := []byte{ 0x01, 0x00, 0x00, 0x00, byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize), } input = append(input, frame7Header...) input = append(input, bytes.Repeat([]byte{'Z'}, int(frameSize))...) reader := bytes.NewReader(input) var builder strings.Builder err := decodeDockerLogStream(reader, &builder, true) // Should complete without error (graceful truncation) assert.NoError(t, err) // Should have read 6 frames (4.8MB total, stopping before 7th would exceed 5MB limit) expectedSize := int(frameSize) * 6 assert.Equal(t, expectedSize, builder.Len()) // Should contain A-F but not Z result := builder.String() assert.Contains(t, result, "A") assert.Contains(t, result, "F") assert.NotContains(t, result, "Z") }) } func TestShouldExcludeContainer(t *testing.T) { tests := []struct { name string containerName string patterns []string expected bool }{ { name: "empty patterns excludes nothing", containerName: "any-container", patterns: []string{}, expected: false, }, { name: "exact match - excluded", containerName: "test-web", patterns: []string{"test-web", "test-api"}, expected: true, }, { name: "exact match - not excluded", containerName: "prod-web", patterns: []string{"test-web", "test-api"}, expected: false, }, { name: "wildcard prefix match - excluded", containerName: "test-web", patterns: []string{"test-*"}, expected: true, }, { name: "wildcard prefix match - not excluded", containerName: "prod-web", patterns: []string{"test-*"}, expected: false, }, { name: "wildcard suffix match - excluded", containerName: "myapp-staging", patterns: []string{"*-staging"}, expected: true, }, { name: "wildcard suffix match - not excluded", containerName: "myapp-prod", patterns: []string{"*-staging"}, expected: false, }, { name: "wildcard both sides match - excluded", containerName: "test-myapp-staging", patterns: []string{"*-myapp-*"}, expected: true, }, { name: "wildcard both sides match - not excluded", containerName: "prod-yourapp-live", patterns: []string{"*-myapp-*"}, expected: false, }, { name: "multiple patterns - matches first", containerName: "test-container", patterns: []string{"test-*", "*-staging"}, expected: true, }, { name: "multiple patterns - matches second", containerName: "myapp-staging", patterns: []string{"test-*", "*-staging"}, expected: true, }, { name: "multiple patterns - no match", containerName: "prod-web", patterns: []string{"test-*", "*-staging"}, expected: false, }, { name: "mixed exact and wildcard - exact match", containerName: "temp-container", patterns: []string{"temp-container", "test-*"}, expected: true, }, { name: "mixed exact and wildcard - wildcard match", containerName: "test-web", patterns: []string{"temp-container", "test-*"}, expected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dm := &dockerManager{ excludeContainers: tt.patterns, } result := dm.shouldExcludeContainer(tt.containerName) assert.Equal(t, tt.expected, result) }) } } func TestAnsiEscapePattern(t *testing.T) { tests := []struct { name string input string expected string }{ { name: "no ANSI codes", input: "Hello, World!", expected: "Hello, World!", }, { name: "simple color code", input: "\x1b[34mINFO\x1b[0m client mode", expected: "INFO client mode", }, { name: "multiple color codes", input: "\x1b[31mERROR\x1b[0m: \x1b[33mWarning\x1b[0m message", expected: "ERROR: Warning message", }, { name: "bold and color", input: "\x1b[1;32mSUCCESS\x1b[0m", expected: "SUCCESS", }, { name: "cursor movement codes", input: "Line 1\x1b[KLine 2", expected: "Line 1Line 2", }, { name: "256 color code", input: "\x1b[38;5;196mRed text\x1b[0m", expected: "Red text", }, { name: "RGB/truecolor code", input: "\x1b[38;2;255;0;0mRed text\x1b[0m", expected: "Red text", }, { name: "mixed content with newlines", input: "\x1b[34m2024-01-01 12:00:00\x1b[0m INFO Starting\n\x1b[31m2024-01-01 12:00:01\x1b[0m ERROR Failed", expected: "2024-01-01 12:00:00 INFO Starting\n2024-01-01 12:00:01 ERROR Failed", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := ansiEscapePattern.ReplaceAllString(tt.input, "") assert.Equal(t, tt.expected, result) }) } } func TestConvertContainerPortsToString(t *testing.T) { type port = struct { PublicPort uint16 IP string } tests := []struct { name string ports []port expected string }{ { name: "empty ports", ports: nil, expected: "", }, { name: "single port", ports: []port{ {PublicPort: 80, IP: "0.0.0.0"}, }, expected: "80", }, { name: "single port with non-default IP", ports: []port{ {PublicPort: 80, IP: "1.2.3.4"}, }, expected: "1.2.3.4:80", }, { name: "ipv6 default ip", ports: []port{ {PublicPort: 80, IP: "::"}, }, expected: "80", }, { name: "zero PublicPort is skipped", ports: []port{ {PublicPort: 0, IP: "0.0.0.0"}, {PublicPort: 80, IP: "0.0.0.0"}, }, expected: "80", }, { name: "ports sorted ascending by PublicPort", ports: []port{ {PublicPort: 443, IP: "0.0.0.0"}, {PublicPort: 80, IP: "0.0.0.0"}, {PublicPort: 8080, IP: "0.0.0.0"}, }, expected: "80, 443, 8080", }, { name: "duplicates are deduplicated", ports: []port{ {PublicPort: 80, IP: "0.0.0.0"}, {PublicPort: 80, IP: "0.0.0.0"}, {PublicPort: 443, IP: "0.0.0.0"}, }, expected: "80, 443", }, { name: "multiple ports with different IPs", ports: []port{ {PublicPort: 80, IP: "0.0.0.0"}, {PublicPort: 443, IP: "1.2.3.4"}, }, expected: "80, 1.2.3.4:443", }, { name: "ports slice is nilled after call", ports: []port{ {PublicPort: 8080, IP: "0.0.0.0"}, }, expected: "8080", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctr := &container.ApiInfo{} for _, p := range tt.ports { ctr.Ports = append(ctr.Ports, struct { PublicPort uint16 IP string }{PublicPort: p.PublicPort, IP: p.IP}) } result := convertContainerPortsToString(ctr) assert.Equal(t, tt.expected, result) // Ports slice must be cleared to prevent bleed-over into the next response assert.Nil(t, ctr.Ports, "ctr.Ports should be nil after formatContainerPorts") }) } } ================================================ FILE: agent/emmc_common.go ================================================ package agent import ( "fmt" "strconv" "strings" ) func isEmmcBlockName(name string) bool { if !strings.HasPrefix(name, "mmcblk") { return false } suffix := strings.TrimPrefix(name, "mmcblk") if suffix == "" { return false } for _, c := range suffix { if c < '0' || c > '9' { return false } } return true } func parseHexOrDecByte(s string) (uint8, bool) { s = strings.TrimSpace(s) if s == "" { return 0, false } base := 10 if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { base = 16 s = s[2:] } parsed, err := strconv.ParseUint(s, base, 8) if err != nil { return 0, false } return uint8(parsed), true } func parseHexBytePair(s string) (uint8, uint8, bool) { fields := strings.Fields(s) if len(fields) < 2 { return 0, 0, false } a, okA := parseHexOrDecByte(fields[0]) b, okB := parseHexOrDecByte(fields[1]) if !okA && !okB { return 0, 0, false } return a, b, true } func emmcSmartStatus(preEOL uint8) string { switch preEOL { case 0x01: return "PASSED" case 0x02: return "WARNING" case 0x03: return "FAILED" default: return "UNKNOWN" } } func emmcPreEOLString(preEOL uint8) string { switch preEOL { case 0x01: return "0x01 (normal)" case 0x02: return "0x02 (warning)" case 0x03: return "0x03 (urgent)" default: return fmt.Sprintf("0x%02x", preEOL) } } func emmcLifeTimeString(v uint8) string { // JEDEC eMMC: 0x01..0x0A => 0-100% used in 10% steps, 0x0B => exceeded. switch { case v == 0: return "0x00 (not reported)" case v >= 0x01 && v <= 0x0A: low := int(v-1) * 10 high := int(v) * 10 return fmt.Sprintf("0x%02x (%d-%d%% used)", v, low, high) case v == 0x0B: return "0x0b (>100% used)" default: return fmt.Sprintf("0x%02x", v) } } ================================================ FILE: agent/emmc_common_test.go ================================================ package agent import "testing" func TestParseHexOrDecByte(t *testing.T) { tests := []struct { in string want uint8 ok bool }{ {"0x01", 1, true}, {"0X0b", 11, true}, {"01", 1, true}, {" 3 ", 3, true}, {"", 0, false}, {"0x", 0, false}, {"nope", 0, false}, } for _, tt := range tests { got, ok := parseHexOrDecByte(tt.in) if ok != tt.ok || got != tt.want { t.Fatalf("parseHexOrDecByte(%q) = (%d,%v), want (%d,%v)", tt.in, got, ok, tt.want, tt.ok) } } } func TestParseHexBytePair(t *testing.T) { a, b, ok := parseHexBytePair("0x01 0x02\n") if !ok || a != 1 || b != 2 { t.Fatalf("parseHexBytePair hex = (%d,%d,%v), want (1,2,true)", a, b, ok) } a, b, ok = parseHexBytePair("01 02") if !ok || a != 1 || b != 2 { t.Fatalf("parseHexBytePair dec = (%d,%d,%v), want (1,2,true)", a, b, ok) } _, _, ok = parseHexBytePair("0x01") if ok { t.Fatalf("parseHexBytePair short input ok=true, want false") } } func TestEmmcSmartStatus(t *testing.T) { if got := emmcSmartStatus(0x01); got != "PASSED" { t.Fatalf("emmcSmartStatus(0x01) = %q, want PASSED", got) } if got := emmcSmartStatus(0x02); got != "WARNING" { t.Fatalf("emmcSmartStatus(0x02) = %q, want WARNING", got) } if got := emmcSmartStatus(0x03); got != "FAILED" { t.Fatalf("emmcSmartStatus(0x03) = %q, want FAILED", got) } if got := emmcSmartStatus(0x00); got != "UNKNOWN" { t.Fatalf("emmcSmartStatus(0x00) = %q, want UNKNOWN", got) } } func TestIsEmmcBlockName(t *testing.T) { cases := []struct { name string ok bool }{ {"mmcblk0", true}, {"mmcblk1", true}, {"mmcblk10", true}, {"mmcblk0p1", false}, {"sda", false}, {"mmcblk", false}, {"mmcblkA", false}, } for _, c := range cases { if got := isEmmcBlockName(c.name); got != c.ok { t.Fatalf("isEmmcBlockName(%q) = %v, want %v", c.name, got, c.ok) } } } ================================================ FILE: agent/emmc_linux.go ================================================ //go:build linux package agent import ( "os" "path/filepath" "strconv" "strings" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/smart" ) // emmcSysfsRoot is a test hook; production value is "/sys". var emmcSysfsRoot = "/sys" type emmcHealth struct { model string serial string revision string capacity uint64 preEOL uint8 lifeA uint8 lifeB uint8 } func scanEmmcDevices() []*DeviceInfo { blockDir := filepath.Join(emmcSysfsRoot, "class", "block") entries, err := os.ReadDir(blockDir) if err != nil { return nil } devices := make([]*DeviceInfo, 0, 2) for _, ent := range entries { name := ent.Name() if !isEmmcBlockName(name) { continue } deviceDir := filepath.Join(blockDir, name, "device") if !hasEmmcHealthFiles(deviceDir) { continue } devPath := filepath.Join("/dev", name) devices = append(devices, &DeviceInfo{ Name: devPath, Type: "emmc", InfoName: devPath + " [eMMC]", Protocol: "MMC", }) } return devices } func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) { if deviceInfo == nil || deviceInfo.Name == "" { return false, nil } base := filepath.Base(deviceInfo.Name) if !isEmmcBlockName(base) && !strings.EqualFold(deviceInfo.Type, "emmc") && !strings.EqualFold(deviceInfo.Type, "mmc") { return false, nil } health, ok := readEmmcHealth(base) if !ok { return false, nil } // Normalize the device type to keep pruning logic stable across refreshes. deviceInfo.Type = "emmc" key := health.serial if key == "" { key = filepath.Join("/dev", base) } status := emmcSmartStatus(health.preEOL) attrs := []*smart.SmartAttribute{ { Name: "PreEOLInfo", RawValue: uint64(health.preEOL), RawString: emmcPreEOLString(health.preEOL), }, { Name: "DeviceLifeTimeEstA", RawValue: uint64(health.lifeA), RawString: emmcLifeTimeString(health.lifeA), }, { Name: "DeviceLifeTimeEstB", RawValue: uint64(health.lifeB), RawString: emmcLifeTimeString(health.lifeB), }, } sm.Lock() defer sm.Unlock() if _, exists := sm.SmartDataMap[key]; !exists { sm.SmartDataMap[key] = &smart.SmartData{} } data := sm.SmartDataMap[key] data.ModelName = health.model data.SerialNumber = health.serial data.FirmwareVersion = health.revision data.Capacity = health.capacity data.Temperature = 0 data.SmartStatus = status data.DiskName = filepath.Join("/dev", base) data.DiskType = "emmc" data.Attributes = attrs return true, nil } func readEmmcHealth(blockName string) (emmcHealth, bool) { var out emmcHealth if !isEmmcBlockName(blockName) { return out, false } deviceDir := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "device") preEOL, okPre := readHexByteFile(filepath.Join(deviceDir, "pre_eol_info")) // Some kernels expose EXT_CSD lifetime via "life_time" (two bytes), others as // separate files. Support both. lifeA, lifeB, okLife := readLifeTime(deviceDir) if !okPre && !okLife { return out, false } out.preEOL = preEOL out.lifeA = lifeA out.lifeB = lifeB out.model = utils.ReadStringFile(filepath.Join(deviceDir, "name")) out.serial = utils.ReadStringFile(filepath.Join(deviceDir, "serial")) out.revision = utils.ReadStringFile(filepath.Join(deviceDir, "prv")) if capBytes, ok := readBlockCapacityBytes(blockName); ok { out.capacity = capBytes } return out, true } func readLifeTime(deviceDir string) (uint8, uint8, bool) { if content, ok := utils.ReadStringFileOK(filepath.Join(deviceDir, "life_time")); ok { a, b, ok := parseHexBytePair(content) return a, b, ok } a, okA := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_a")) b, okB := readHexByteFile(filepath.Join(deviceDir, "device_life_time_est_typ_b")) if okA || okB { return a, b, true } return 0, 0, false } func readBlockCapacityBytes(blockName string) (uint64, bool) { sizePath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "size") lbsPath := filepath.Join(emmcSysfsRoot, "class", "block", blockName, "queue", "logical_block_size") sizeStr, ok := utils.ReadStringFileOK(sizePath) if !ok { return 0, false } sectors, err := strconv.ParseUint(sizeStr, 10, 64) if err != nil || sectors == 0 { return 0, false } lbsStr, ok := utils.ReadStringFileOK(lbsPath) logicalBlockSize := uint64(512) if ok { if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 { logicalBlockSize = parsed } } return sectors * logicalBlockSize, true } func readHexByteFile(path string) (uint8, bool) { content, ok := utils.ReadStringFileOK(path) if !ok { return 0, false } b, ok := parseHexOrDecByte(content) return b, ok } func hasEmmcHealthFiles(deviceDir string) bool { entries, err := os.ReadDir(deviceDir) if err != nil { return false } for _, ent := range entries { switch ent.Name() { case "pre_eol_info", "life_time", "device_life_time_est_typ_a", "device_life_time_est_typ_b": return true } } return false } ================================================ FILE: agent/emmc_linux_test.go ================================================ //go:build linux package agent import ( "os" "path/filepath" "testing" "github.com/henrygd/beszel/internal/entities/smart" ) func TestEmmcMockSysfsScanAndCollect(t *testing.T) { tmp := t.TempDir() prev := emmcSysfsRoot emmcSysfsRoot = tmp t.Cleanup(func() { emmcSysfsRoot = prev }) // Fake: /sys/class/block/mmcblk0 mmcDeviceDir := filepath.Join(tmp, "class", "block", "mmcblk0", "device") mmcQueueDir := filepath.Join(tmp, "class", "block", "mmcblk0", "queue") if err := os.MkdirAll(mmcDeviceDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(mmcQueueDir, 0o755); err != nil { t.Fatal(err) } write := func(path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } write(filepath.Join(mmcDeviceDir, "pre_eol_info"), "0x02\n") write(filepath.Join(mmcDeviceDir, "life_time"), "0x04 0x05\n") write(filepath.Join(mmcDeviceDir, "name"), "H26M52103FMR\n") write(filepath.Join(mmcDeviceDir, "serial"), "01234567\n") write(filepath.Join(mmcDeviceDir, "prv"), "0x08\n") write(filepath.Join(mmcQueueDir, "logical_block_size"), "512\n") write(filepath.Join(tmp, "class", "block", "mmcblk0", "size"), "1024\n") // sectors devs := scanEmmcDevices() if len(devs) != 1 { t.Fatalf("scanEmmcDevices() = %d devices, want 1", len(devs)) } if devs[0].Name != "/dev/mmcblk0" || devs[0].Type != "emmc" { t.Fatalf("scanEmmcDevices()[0] = %+v, want Name=/dev/mmcblk0 Type=emmc", devs[0]) } sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}} ok, err := sm.collectEmmcHealth(devs[0]) if err != nil || !ok { t.Fatalf("collectEmmcHealth() = (ok=%v, err=%v), want (true,nil)", ok, err) } if len(sm.SmartDataMap) != 1 { t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap)) } var got *smart.SmartData for _, v := range sm.SmartDataMap { got = v break } if got == nil { t.Fatalf("SmartDataMap value nil") } if got.DiskType != "emmc" || got.DiskName != "/dev/mmcblk0" { t.Fatalf("disk fields = (type=%q name=%q), want (emmc,/dev/mmcblk0)", got.DiskType, got.DiskName) } if got.SmartStatus != "WARNING" { t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus) } if got.SerialNumber != "01234567" || got.ModelName == "" || got.Capacity == 0 { t.Fatalf("identity fields = (model=%q serial=%q cap=%d), want non-empty model, serial 01234567, cap>0", got.ModelName, got.SerialNumber, got.Capacity) } if len(got.Attributes) < 3 { t.Fatalf("attributes len=%d, want >= 3", len(got.Attributes)) } } ================================================ FILE: agent/emmc_stub.go ================================================ //go:build !linux package agent // Non-Linux builds: eMMC health via sysfs is not available. func scanEmmcDevices() []*DeviceInfo { return nil } func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) { return false, nil } ================================================ FILE: agent/fingerprint.go ================================================ package agent import ( "crypto/sha256" "encoding/hex" "errors" "os" "path/filepath" "strings" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/host" ) const fingerprintFileName = "fingerprint" // knownBadUUID is a commonly known "product_uuid" that is not unique across systems. const knownBadUUID = "03000200-0400-0500-0006-000700080009" // GetFingerprint returns the agent fingerprint. It first tries to read a saved // fingerprint from the data directory. If not found (or dataDir is empty), it // generates one from system properties. The hostname and cpuModel parameters are // used as fallback material if host.HostID() fails. If either is empty, they // are fetched from the system automatically. // // If a new fingerprint is generated and a dataDir is provided, it is saved. func GetFingerprint(dataDir, hostname, cpuModel string) string { if dataDir != "" { if fp, err := readFingerprint(dataDir); err == nil { return fp } } fp := generateFingerprint(hostname, cpuModel) if dataDir != "" { _ = SaveFingerprint(dataDir, fp) } return fp } // generateFingerprint creates a fingerprint from system properties. // It tries host.HostID() first, falling back to hostname + cpuModel. // If hostname or cpuModel are empty, they are fetched from the system. func generateFingerprint(hostname, cpuModel string) string { fingerprint, err := host.HostID() if err != nil || fingerprint == "" || fingerprint == knownBadUUID { if hostname == "" { hostname, _ = os.Hostname() } if cpuModel == "" { if info, err := cpu.Info(); err == nil && len(info) > 0 { cpuModel = info[0].ModelName } } fingerprint = hostname + cpuModel } sum := sha256.Sum256([]byte(fingerprint)) return hex.EncodeToString(sum[:24]) } // readFingerprint reads the saved fingerprint from the data directory. func readFingerprint(dataDir string) (string, error) { fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName)) if err != nil { return "", err } s := strings.TrimSpace(string(fp)) if s == "" { return "", errors.New("fingerprint file is empty") } return s, nil } // SaveFingerprint writes the fingerprint to the data directory. func SaveFingerprint(dataDir, fingerprint string) error { return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644) } // DeleteFingerprint removes the saved fingerprint file from the data directory. // Returns nil if the file does not exist (idempotent). func DeleteFingerprint(dataDir string) error { err := os.Remove(filepath.Join(dataDir, fingerprintFileName)) if errors.Is(err, os.ErrNotExist) { return nil } return err } ================================================ FILE: agent/fingerprint_test.go ================================================ //go:build testing package agent import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGetFingerprint(t *testing.T) { t.Run("reads existing fingerprint from file", func(t *testing.T) { dir := t.TempDir() expected := "abc123def456" err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644) require.NoError(t, err) fp := GetFingerprint(dir, "", "") assert.Equal(t, expected, fp) }) t.Run("trims whitespace from file", func(t *testing.T) { dir := t.TempDir() err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644) require.NoError(t, err) fp := GetFingerprint(dir, "", "") assert.Equal(t, "abc123", fp) }) t.Run("generates fingerprint when file does not exist", func(t *testing.T) { dir := t.TempDir() fp := GetFingerprint(dir, "", "") assert.NotEmpty(t, fp) }) t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) { fp := GetFingerprint("", "", "") assert.NotEmpty(t, fp) }) t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) { fp1 := GetFingerprint("", "myhost", "mycpu") fp2 := GetFingerprint("", "myhost", "mycpu") assert.Equal(t, fp1, fp2) }) t.Run("prefers saved fingerprint over generated", func(t *testing.T) { dir := t.TempDir() require.NoError(t, SaveFingerprint(dir, "saved-fp")) fp := GetFingerprint(dir, "anyhost", "anycpu") assert.Equal(t, "saved-fp", fp) }) } func TestSaveFingerprint(t *testing.T) { t.Run("saves fingerprint to file", func(t *testing.T) { dir := t.TempDir() err := SaveFingerprint(dir, "abc123") require.NoError(t, err) content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName)) require.NoError(t, err) assert.Equal(t, "abc123", string(content)) }) t.Run("overwrites existing fingerprint", func(t *testing.T) { dir := t.TempDir() require.NoError(t, SaveFingerprint(dir, "old")) require.NoError(t, SaveFingerprint(dir, "new")) content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName)) require.NoError(t, err) assert.Equal(t, "new", string(content)) }) } func TestDeleteFingerprint(t *testing.T) { t.Run("deletes existing fingerprint", func(t *testing.T) { dir := t.TempDir() fp := filepath.Join(dir, fingerprintFileName) err := os.WriteFile(fp, []byte("abc123"), 0644) require.NoError(t, err) err = DeleteFingerprint(dir) require.NoError(t, err) // Verify file is gone _, err = os.Stat(fp) assert.True(t, os.IsNotExist(err)) }) t.Run("no error when file does not exist", func(t *testing.T) { dir := t.TempDir() err := DeleteFingerprint(dir) assert.NoError(t, err) }) } ================================================ FILE: agent/gpu.go ================================================ package agent import ( "bufio" "bytes" "encoding/json" "fmt" "log/slog" "maps" "os/exec" "regexp" "runtime" "strconv" "strings" "sync" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) const ( // Commands nvidiaSmiCmd string = "nvidia-smi" rocmSmiCmd string = "rocm-smi" tegraStatsCmd string = "tegrastats" nvtopCmd string = "nvtop" powermetricsCmd string = "powermetrics" macmonCmd string = "macmon" noGPUFoundMsg string = "no GPU found - see https://beszel.dev/guide/gpu" // Command retry and timeout constants retryWaitTime time.Duration = 5 * time.Second maxFailureRetries int = 5 // Unit Conversions mebibytesInAMegabyte float64 = 1.024 // nvidia-smi reports memory in MiB milliwattsInAWatt float64 = 1000.0 // tegrastats reports power in mW ) // GPUManager manages data collection for GPUs (either Nvidia or AMD) type GPUManager struct { sync.Mutex GpuDataMap map[string]*system.GPUData // lastAvgData stores the last calculated averages for each GPU // Used when a collection happens before new data arrives (Count == 0) lastAvgData map[string]system.GPUData // Per-cache-key tracking for delta calculations // cacheKey -> gpuId -> snapshot of last count/usage/power values lastSnapshots map[uint16]map[string]*gpuSnapshot } // gpuSnapshot stores the last observed incremental values for delta tracking type gpuSnapshot struct { count uint32 usage float64 power float64 powerPkg float64 engines map[string]float64 } // RocmSmiJson represents the JSON structure of rocm-smi output type RocmSmiJson struct { ID string `json:"GUID"` Name string `json:"Card series"` Temperature string `json:"Temperature (Sensor edge) (C)"` MemoryUsed string `json:"VRAM Total Used Memory (B)"` MemoryTotal string `json:"VRAM Total Memory (B)"` Usage string `json:"GPU use (%)"` PowerPackage string `json:"Average Graphics Package Power (W)"` PowerSocket string `json:"Current Socket Graphics Package Power (W)"` } // gpuCollector defines a collector for a specific GPU management utility (nvidia-smi or rocm-smi) type gpuCollector struct { name string cmdArgs []string parse func([]byte) bool // returns true if valid data was found buf []byte bufSize uint16 } var errNoValidData = fmt.Errorf("no valid GPU data found") // Error for missing data // collectorSource identifies a selectable GPU collector in GPU_COLLECTOR. type collectorSource string const ( collectorSourceNVTop collectorSource = collectorSource(nvtopCmd) collectorSourceNVML collectorSource = "nvml" collectorSourceNvidiaSMI collectorSource = collectorSource(nvidiaSmiCmd) collectorSourceIntelGpuTop collectorSource = collectorSource(intelGpuStatsCmd) collectorSourceAmdSysfs collectorSource = "amd_sysfs" collectorSourceRocmSMI collectorSource = collectorSource(rocmSmiCmd) collectorSourceMacmon collectorSource = collectorSource(macmonCmd) collectorSourcePowermetrics collectorSource = collectorSource(powermetricsCmd) collectorGroupNvidia string = "nvidia" collectorGroupIntel string = "intel" collectorGroupAmd string = "amd" collectorGroupApple string = "apple" ) func isValidCollectorSource(source collectorSource) bool { switch source { case collectorSourceNVTop, collectorSourceNVML, collectorSourceNvidiaSMI, collectorSourceIntelGpuTop, collectorSourceAmdSysfs, collectorSourceRocmSMI, collectorSourceMacmon, collectorSourcePowermetrics: return true } return false } // gpuCapabilities describes detected GPU tooling and sysfs support on the host. type gpuCapabilities struct { hasNvidiaSmi bool hasRocmSmi bool hasAmdSysfs bool hasTegrastats bool hasIntelGpuTop bool hasNvtop bool hasMacmon bool hasPowermetrics bool } type collectorDefinition struct { group string available bool start func(onFailure func()) bool deprecationWarning string } // starts and manages the ongoing collection of GPU data for the specified GPU management utility func (c *gpuCollector) start() { for { err := c.collect() if err != nil { if err == errNoValidData { slog.Warn(c.name + " found no valid GPU data, stopping") break } slog.Warn(c.name+" failed, restarting", "err", err) time.Sleep(retryWaitTime) continue } } } // collect executes the command, parses output with the assigned parser function func (c *gpuCollector) collect() error { cmd := exec.Command(c.name, c.cmdArgs...) stdout, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } scanner := bufio.NewScanner(stdout) if c.buf == nil { c.buf = make([]byte, 0, c.bufSize) } scanner.Buffer(c.buf, bufio.MaxScanTokenSize) for scanner.Scan() { hasValidData := c.parse(scanner.Bytes()) if !hasValidData { return errNoValidData } } if err := scanner.Err(); err != nil { return fmt.Errorf("scanner error: %w", err) } return cmd.Wait() } // getJetsonParser returns a function to parse the output of tegrastats and update the GPUData map func (gm *GPUManager) getJetsonParser() func(output []byte) bool { // use closure to avoid recompiling the regex ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`) gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`) tempPattern := regexp.MustCompile(`(?:tj|GPU)@(\d+\.?\d*)C`) // Orin Nano / NX do not have GPU specific power monitor // TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV)\s+(\d+)mW|VDD_SYS_GPU\s+(\d+)/\d+`) // 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() // Parse RAM usage ramMatches := ramPattern.FindSubmatch(output) if ramMatches != nil { gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64) gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64) } // Parse GR3D (GPU) usage gr3dMatches := gr3dPattern.FindSubmatch(output) if gr3dMatches != nil { gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64) gpuData.Usage += gr3dUsage } // Parse temperature tempMatches := tempPattern.FindSubmatch(output) if tempMatches != nil { gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64) } // Parse power usage powerMatches := powerPattern.FindSubmatch(output) if powerMatches != nil { // powerMatches[2] is the "(GPU_SOC|CPU_GPU_CV) mW" capture // powerMatches[3] is the "VDD_SYS_GPU /" capture powerStr := string(powerMatches[2]) if powerStr == "" { powerStr = string(powerMatches[3]) } power, _ := strconv.ParseFloat(powerStr, 64) gpuData.Power += power / milliwattsInAWatt } gpuData.Count++ return true } } // parseNvidiaData parses the output of nvidia-smi and updates the GPUData map func (gm *GPUManager) parseNvidiaData(output []byte) bool { gm.Lock() defer gm.Unlock() scanner := bufio.NewScanner(bytes.NewReader(output)) var valid bool for scanner.Scan() { line := scanner.Text() // Or use scanner.Bytes() for []byte fields := strings.Split(strings.TrimSpace(line), ", ") if len(fields) < 7 { continue } valid = true id := fields[0] temp, _ := strconv.ParseFloat(fields[2], 64) memoryUsage, _ := strconv.ParseFloat(fields[3], 64) totalMemory, _ := strconv.ParseFloat(fields[4], 64) usage, _ := strconv.ParseFloat(fields[5], 64) power, _ := strconv.ParseFloat(fields[6], 64) // add gpu if not exists if _, ok := gm.GpuDataMap[id]; !ok { name := strings.TrimPrefix(fields[1], "NVIDIA ") gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")} } // update gpu data gpu := gm.GpuDataMap[id] gpu.Temperature = temp gpu.MemoryUsed = memoryUsage / mebibytesInAMegabyte gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte gpu.Usage += usage gpu.Power += power gpu.Count++ } return valid } // parseAmdData parses the output of rocm-smi and updates the GPUData map func (gm *GPUManager) parseAmdData(output []byte) bool { var rocmSmiInfo map[string]RocmSmiJson if err := json.Unmarshal(output, &rocmSmiInfo); err != nil || len(rocmSmiInfo) == 0 { return false } gm.Lock() defer gm.Unlock() for _, v := range rocmSmiInfo { var power float64 if v.PowerPackage != "" { power, _ = strconv.ParseFloat(v.PowerPackage, 64) } else { power, _ = strconv.ParseFloat(v.PowerSocket, 64) } memoryUsage, _ := strconv.ParseFloat(v.MemoryUsed, 64) totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64) usage, _ := strconv.ParseFloat(v.Usage, 64) id := v.ID if _, ok := gm.GpuDataMap[id]; !ok { gm.GpuDataMap[id] = &system.GPUData{Name: v.Name} } gpu := gm.GpuDataMap[id] gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64) gpu.MemoryUsed = utils.BytesToMegabytes(memoryUsage) gpu.MemoryTotal = utils.BytesToMegabytes(totalMemory) gpu.Usage += usage gpu.Power += power gpu.Count++ } return true } // GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData { gm.Lock() defer gm.Unlock() gm.initializeSnapshots(cacheKey) nameCounts := gm.countGPUNames() gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap)) for id, gpu := range gm.GpuDataMap { gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey) gm.updateInstantaneousValues(&gpuAvg, gpu) gm.storeSnapshot(id, gpu, cacheKey) // Append id to name if there are multiple GPUs with the same name if nameCounts[gpu.Name] > 1 { gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id) } gpuData[id] = gpuAvg } slog.Debug("GPU", "data", gpuData) return gpuData } // initializeSnapshots ensures snapshot maps are initialized for the given cache key func (gm *GPUManager) initializeSnapshots(cacheKey uint16) { if gm.lastAvgData == nil { gm.lastAvgData = make(map[string]system.GPUData) } if gm.lastSnapshots == nil { gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot) } if gm.lastSnapshots[cacheKey] == nil { gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot) } } // countGPUNames returns a map of GPU names to their occurrence count func (gm *GPUManager) countGPUNames() map[string]int { nameCounts := make(map[string]int) for _, gpu := range gm.GpuDataMap { nameCounts[gpu.Name]++ } return nameCounts } // calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData { lastSnapshot := gm.lastSnapshots[cacheKey][id] currentCount := uint32(gpu.Count) deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot) // If no new data arrived if deltaCount == 0 { // If GPU appears suspended (instantaneous values are 0), return zero values // Otherwise return last known average for temporary collection gaps if gpu.Temperature == 0 && gpu.MemoryUsed == 0 { return system.GPUData{Name: gpu.Name} } return gm.lastAvgData[id] // zero value if not found } // Calculate new average gpuAvg := *gpu deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot) gpuAvg.Power = utils.TwoDecimals(deltaPower / float64(deltaCount)) if gpu.Engines != nil { // make fresh map for averaged engine metrics to avoid mutating // the accumulator map stored in gm.GpuDataMap gpuAvg.Engines = make(map[string]float64, len(gpu.Engines)) gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount) gpuAvg.PowerPkg = utils.TwoDecimals(deltaPowerPkg / float64(deltaCount)) } else { gpuAvg.Usage = utils.TwoDecimals(deltaUsage / float64(deltaCount)) } gm.lastAvgData[id] = gpuAvg return gpuAvg } // calculateDeltaCount returns the change in count since the last snapshot func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 { if lastSnapshot != nil { return currentCount - lastSnapshot.count } return currentCount } // calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) { if lastSnapshot != nil { return gpu.Usage - lastSnapshot.usage, gpu.Power - lastSnapshot.power, gpu.PowerPkg - lastSnapshot.powerPkg } return gpu.Usage, gpu.Power, gpu.PowerPkg } // calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 { maxEngineUsage := 0.0 for name, engine := range gpu.Engines { var deltaEngine float64 if lastSnapshot != nil && lastSnapshot.engines != nil { deltaEngine = engine - lastSnapshot.engines[name] } else { deltaEngine = engine } gpuAvg.Engines[name] = utils.TwoDecimals(deltaEngine / float64(deltaCount)) maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount)) } return utils.TwoDecimals(maxEngineUsage) } // updateInstantaneousValues updates values that should reflect current state, not averages func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) { gpuAvg.Temperature = utils.TwoDecimals(gpu.Temperature) gpuAvg.MemoryUsed = utils.TwoDecimals(gpu.MemoryUsed) gpuAvg.MemoryTotal = utils.TwoDecimals(gpu.MemoryTotal) } // storeSnapshot saves the current GPU state for this cache key func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) { snapshot := &gpuSnapshot{ count: uint32(gpu.Count), usage: gpu.Usage, power: gpu.Power, powerPkg: gpu.PowerPkg, } if gpu.Engines != nil { snapshot.engines = make(map[string]float64, len(gpu.Engines)) maps.Copy(snapshot.engines, gpu.Engines) } gm.lastSnapshots[cacheKey][id] = snapshot } // discoverGpuCapabilities checks for available GPU tooling and sysfs support. // It only reports capability presence and does not apply policy decisions. func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { caps := gpuCapabilities{ hasAmdSysfs: gm.hasAmdSysfs(), } if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { caps.hasNvidiaSmi = true } if _, err := exec.LookPath(rocmSmiCmd); err == nil { caps.hasRocmSmi = true } if _, err := exec.LookPath(tegraStatsCmd); err == nil { caps.hasTegrastats = true } if _, err := exec.LookPath(intelGpuStatsCmd); err == nil { caps.hasIntelGpuTop = true } if _, err := exec.LookPath(nvtopCmd); err == nil { caps.hasNvtop = true } if runtime.GOOS == "darwin" { if _, err := exec.LookPath(macmonCmd); err == nil { caps.hasMacmon = true } if _, err := exec.LookPath(powermetricsCmd); err == nil { caps.hasPowermetrics = true } } return caps } func hasAnyGpuCollector(caps gpuCapabilities) bool { return caps.hasNvidiaSmi || caps.hasRocmSmi || caps.hasAmdSysfs || caps.hasTegrastats || caps.hasIntelGpuTop || caps.hasNvtop || caps.hasMacmon || caps.hasPowermetrics } func (gm *GPUManager) startIntelCollector() { go func() { failures := 0 for { if err := gm.collectIntelStats(); err != nil { failures++ if failures > maxFailureRetries { break } slog.Warn("Error collecting Intel GPU data; see https://beszel.dev/guide/gpu", "err", err) time.Sleep(retryWaitTime) continue } } }() } func (gm *GPUManager) startNvidiaSmiCollector(intervalSeconds string) { collector := gpuCollector{ name: nvidiaSmiCmd, bufSize: 10 * 1024, cmdArgs: []string{ "-l", intervalSeconds, "--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw", "--format=csv,noheader,nounits", }, parse: gm.parseNvidiaData, } go collector.start() } func (gm *GPUManager) startTegraStatsCollector(intervalMilliseconds string) { collector := gpuCollector{ name: tegraStatsCmd, bufSize: 10 * 1024, cmdArgs: []string{"--interval", intervalMilliseconds}, parse: gm.getJetsonParser(), } go collector.start() } func (gm *GPUManager) startRocmSmiCollector(pollInterval time.Duration) { collector := gpuCollector{ name: rocmSmiCmd, bufSize: 10 * 1024, cmdArgs: []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}, parse: gm.parseAmdData, } go func() { failures := 0 for { if err := collector.collect(); err != nil { failures++ if failures > maxFailureRetries { break } slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err) } time.Sleep(pollInterval) } }() } func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSource]collectorDefinition { return map[collectorSource]collectorDefinition{ collectorSourceNVML: { group: collectorGroupNvidia, available: caps.hasNvidiaSmi, start: func(_ func()) bool { return gm.startNvmlCollector() }, }, collectorSourceNvidiaSMI: { group: collectorGroupNvidia, available: caps.hasNvidiaSmi, start: func(_ func()) bool { gm.startNvidiaSmiCollector("4") // seconds return true }, }, collectorSourceIntelGpuTop: { group: collectorGroupIntel, available: caps.hasIntelGpuTop, start: func(_ func()) bool { gm.startIntelCollector() return true }, }, collectorSourceAmdSysfs: { group: collectorGroupAmd, available: caps.hasAmdSysfs, start: func(_ func()) bool { return gm.startAmdSysfsCollector() }, }, collectorSourceRocmSMI: { group: collectorGroupAmd, available: caps.hasRocmSmi, deprecationWarning: "rocm-smi is deprecated and may be removed in a future release", start: func(_ func()) bool { gm.startRocmSmiCollector(4300 * time.Millisecond) return true }, }, collectorSourceNVTop: { available: caps.hasNvtop, start: func(onFailure func()) bool { gm.startNvtopCollector("30", onFailure) // tens of milliseconds return true }, }, collectorSourceMacmon: { group: collectorGroupApple, available: caps.hasMacmon, start: func(_ func()) bool { gm.startMacmonCollector() return true }, }, collectorSourcePowermetrics: { group: collectorGroupApple, available: caps.hasPowermetrics, start: func(_ func()) bool { gm.startPowermetricsCollector() return true }, }, } } // parseCollectorPriority parses GPU_COLLECTOR and returns valid ordered entries. func parseCollectorPriority(value string) []collectorSource { parts := strings.Split(value, ",") priorities := make([]collectorSource, 0, len(parts)) for _, raw := range parts { name := collectorSource(strings.TrimSpace(strings.ToLower(raw))) if !isValidCollectorSource(name) { if name != "" { slog.Warn("Ignoring unknown GPU collector", "collector", name) } continue } priorities = append(priorities, name) } return priorities } // startNvmlCollector initializes NVML and starts its polling loop. func (gm *GPUManager) startNvmlCollector() bool { collector := &nvmlCollector{gm: gm} if err := collector.init(); err != nil { slog.Warn("Failed to initialize NVML", "err", err) return false } go collector.start() return true } // startAmdSysfsCollector starts AMD GPU collection via sysfs. func (gm *GPUManager) startAmdSysfsCollector() bool { go func() { if err := gm.collectAmdStats(); err != nil { slog.Warn("Error collecting AMD GPU data via sysfs", "err", err) } }() return true } // startCollectorsByPriority starts collectors in order with one source per vendor group. func (gm *GPUManager) startCollectorsByPriority(priorities []collectorSource, caps gpuCapabilities) int { definitions := gm.collectorDefinitions(caps) selectedGroups := make(map[string]bool, 3) started := 0 for i, source := range priorities { definition, ok := definitions[source] if !ok || !definition.available { continue } // nvtop is not a vendor-specific collector, so should only be used if no other collectors are selected or it is first in GPU_COLLECTOR. if source == collectorSourceNVTop { if len(selectedGroups) > 0 { slog.Warn("Skipping nvtop because other collectors are selected") continue } // if nvtop fails, fall back to remaining collectors. remaining := append([]collectorSource(nil), priorities[i+1:]...) if definition.start(func() { gm.startCollectorsByPriority(remaining, caps) }) { started++ return started } } group := definition.group if group == "" || selectedGroups[group] { continue } if definition.deprecationWarning != "" { slog.Warn(definition.deprecationWarning) } if definition.start(nil) { selectedGroups[group] = true started++ } } return started } // resolveLegacyCollectorPriority builds the default collector order when GPU_COLLECTOR is unset. func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []collectorSource { priorities := make([]collectorSource, 0, 4) if caps.hasNvidiaSmi && !caps.hasTegrastats { if nvml, _ := utils.GetEnv("NVML"); nvml == "true" { priorities = append(priorities, collectorSourceNVML, collectorSourceNvidiaSMI) } else { priorities = append(priorities, collectorSourceNvidiaSMI) } } if caps.hasRocmSmi { if val, _ := utils.GetEnv("AMD_SYSFS"); val == "true" { priorities = append(priorities, collectorSourceAmdSysfs) } else { priorities = append(priorities, collectorSourceRocmSMI) } } else if caps.hasAmdSysfs { priorities = append(priorities, collectorSourceAmdSysfs) } if caps.hasIntelGpuTop { priorities = append(priorities, collectorSourceIntelGpuTop) } // Apple collectors are currently opt-in only for testing. // Enable them with GPU_COLLECTOR=macmon or GPU_COLLECTOR=powermetrics. // TODO: uncomment below when Apple collectors are confirmed to be working. // // Prefer macmon on macOS (no sudo). Fall back to powermetrics if present. // if caps.hasMacmon { // priorities = append(priorities, collectorSourceMacmon) // } else if caps.hasPowermetrics { // priorities = append(priorities, collectorSourcePowermetrics) // } // Keep nvtop as a last resort only when no vendor collector exists. if len(priorities) == 0 && caps.hasNvtop { priorities = append(priorities, collectorSourceNVTop) } return priorities } // NewGPUManager creates and initializes a new GPUManager func NewGPUManager() (*GPUManager, error) { if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" { return nil, nil } var gm GPUManager caps := gm.discoverGpuCapabilities() if !hasAnyGpuCollector(caps) { return nil, fmt.Errorf(noGPUFoundMsg) } gm.GpuDataMap = make(map[string]*system.GPUData) // Jetson devices should always use tegrastats (ignore GPU_COLLECTOR). if caps.hasTegrastats { gm.startTegraStatsCollector("3700") return &gm, nil } // if GPU_COLLECTOR is set, start user-defined collectors. if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" { priorities := parseCollectorPriority(collectorConfig) if gm.startCollectorsByPriority(priorities, caps) == 0 { return nil, fmt.Errorf("no configured GPU collectors are available") } return &gm, nil } // auto-detect and start collectors when GPU_COLLECTOR is unset. if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 { return nil, fmt.Errorf(noGPUFoundMsg) } return &gm, nil } ================================================ FILE: agent/gpu_amd_linux.go ================================================ //go:build linux package agent import ( "bufio" "fmt" "log/slog" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) var amdgpuNameCache = struct { sync.RWMutex hits map[string]string misses map[string]struct{} }{ hits: make(map[string]string), misses: make(map[string]struct{}), } // hasAmdSysfs returns true if any AMD GPU sysfs nodes are found func (gm *GPUManager) hasAmdSysfs() bool { cards, err := filepath.Glob("/sys/class/drm/card*/device/vendor") if err != nil { return false } for _, vendorPath := range cards { vendor, err := utils.ReadStringFileLimited(vendorPath, 64) if err == nil && vendor == "0x1002" { return true } } return false } // collectAmdStats collects AMD GPU metrics directly from sysfs to avoid the overhead of rocm-smi func (gm *GPUManager) collectAmdStats() error { sysfsPollInterval := 3000 * time.Millisecond cards, err := filepath.Glob("/sys/class/drm/card*") if err != nil { return err } var amdGpuPaths []string for _, card := range cards { // Ignore symbolic links and non-main card directories if strings.Contains(filepath.Base(card), "-") || !isAmdGpu(card) { continue } amdGpuPaths = append(amdGpuPaths, card) } if len(amdGpuPaths) == 0 { return errNoValidData } slog.Debug("Using sysfs for AMD GPU data collection") failures := 0 for { hasData := false for _, cardPath := range amdGpuPaths { if gm.updateAmdGpuData(cardPath) { hasData = true } } if !hasData { failures++ if failures > maxFailureRetries { return errNoValidData } slog.Warn("No AMD GPU data from sysfs", "failures", failures) time.Sleep(retryWaitTime) continue } failures = 0 time.Sleep(sysfsPollInterval) } } // isAmdGpu checks whether a DRM card path belongs to AMD vendor ID 0x1002. func isAmdGpu(cardPath string) bool { vendor, err := utils.ReadStringFileLimited(filepath.Join(cardPath, "device/vendor"), 64) if err != nil { return false } return vendor == "0x1002" } // updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map. // Returns true if at least some data was successfully read. func (gm *GPUManager) updateAmdGpuData(cardPath string) bool { devicePath := filepath.Join(cardPath, "device") id := filepath.Base(cardPath) // Read all sysfs values first (no lock needed - these can be slow) usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent")) memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used")) memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total")) // if gtt is present, add it to the memory used and total (https://github.com/henrygd/beszel/issues/1569#issuecomment-3837640484) if gttUsed, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_used")); err == nil && gttUsed > 0 { if gttTotal, err := readSysfsFloat(filepath.Join(devicePath, "mem_info_gtt_total")); err == nil { memUsed += gttUsed memTotal += gttTotal } } var temp, power float64 hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*")) for _, hwmonDir := range hwmons { if t, err := readSysfsFloat(filepath.Join(hwmonDir, "temp1_input")); err == nil { temp = t / 1000.0 } if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_average")); err == nil { power += p / 1000000.0 } else if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_input")); err == nil { power += p / 1000000.0 } } // Check if we got any meaningful data if usageErr != nil && memUsedErr != nil && temp == 0 { return false } // Single lock to update all values atomically gm.Lock() defer gm.Unlock() gpu, ok := gm.GpuDataMap[id] if !ok { gpu = &system.GPUData{Name: getAmdGpuName(devicePath)} gm.GpuDataMap[id] = gpu } if usageErr == nil { gpu.Usage += usage } gpu.MemoryUsed = utils.BytesToMegabytes(memUsed) gpu.MemoryTotal = utils.BytesToMegabytes(memTotal) gpu.Temperature = temp gpu.Power += power gpu.Count++ return true } // readSysfsFloat reads and parses a numeric value from a sysfs file. func readSysfsFloat(path string) (float64, error) { val, err := utils.ReadStringFileLimited(path, 64) if err != nil { return 0, err } return strconv.ParseFloat(val, 64) } // normalizeHexID normalizes hex IDs by trimming spaces, lowercasing, and dropping 0x. func normalizeHexID(id string) string { return strings.TrimPrefix(strings.ToLower(strings.TrimSpace(id)), "0x") } // cacheKeyForAmdgpu builds the cache key for a device and optional revision. func cacheKeyForAmdgpu(deviceID, revisionID string) string { if revisionID != "" { return deviceID + ":" + revisionID } return deviceID } // lookupAmdgpuNameInFile resolves an AMDGPU name from amdgpu.ids by device/revision. func lookupAmdgpuNameInFile(deviceID, revisionID, filePath string) (name string, exact bool, found bool) { file, err := os.Open(filePath) if err != nil { return "", false, false } defer file.Close() var byDevice string scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } parts := strings.SplitN(line, ",", 3) if len(parts) != 3 { continue } dev := normalizeHexID(parts[0]) rev := normalizeHexID(parts[1]) productName := strings.TrimSpace(parts[2]) if dev == "" || productName == "" || dev != deviceID { continue } if byDevice == "" { byDevice = productName } if revisionID != "" && rev == revisionID { return productName, true, true } } if byDevice != "" { return byDevice, false, true } return "", false, false } // getCachedAmdgpuName returns cached hit/miss status for the given device/revision. func getCachedAmdgpuName(deviceID, revisionID string) (name string, found bool, done bool) { // Build the list of cache keys to check. We always look up the exact device+revision key. // When revisionID is set, we also look up deviceID alone, since the cache may store a // device-only fallback when we couldn't resolve the exact revision. keys := []string{cacheKeyForAmdgpu(deviceID, revisionID)} if revisionID != "" { keys = append(keys, deviceID) } knownMisses := 0 amdgpuNameCache.RLock() defer amdgpuNameCache.RUnlock() for _, key := range keys { if name, ok := amdgpuNameCache.hits[key]; ok { return name, true, true } if _, ok := amdgpuNameCache.misses[key]; ok { knownMisses++ } } // done=true means "don't bother doing slow lookup": we either found a name (above) or // every key we checked was already a known miss, so we've tried before and failed. return "", false, knownMisses == len(keys) } // normalizeAmdgpuName trims standard suffixes from AMDGPU product names. func normalizeAmdgpuName(name string) string { for _, suffix := range []string{" Graphics", " Series"} { name = strings.TrimSuffix(name, suffix) } return name } // cacheAmdgpuName stores a resolved AMDGPU name in the lookup cache. func cacheAmdgpuName(deviceID, revisionID, name string, exact bool) { name = normalizeAmdgpuName(name) amdgpuNameCache.Lock() defer amdgpuNameCache.Unlock() if exact && revisionID != "" { amdgpuNameCache.hits[cacheKeyForAmdgpu(deviceID, revisionID)] = name } amdgpuNameCache.hits[deviceID] = name } // cacheMissingAmdgpuName records unresolved device/revision lookups. func cacheMissingAmdgpuName(deviceID, revisionID string) { amdgpuNameCache.Lock() defer amdgpuNameCache.Unlock() amdgpuNameCache.misses[deviceID] = struct{}{} if revisionID != "" { amdgpuNameCache.misses[cacheKeyForAmdgpu(deviceID, revisionID)] = struct{}{} } } // getAmdGpuName attempts to get a descriptive GPU name. // First tries product_name (rarely available), then looks up the PCI device ID. // Falls back to showing the raw device ID if not found in the lookup table. func getAmdGpuName(devicePath string) string { // Try product_name first (works for some enterprise GPUs) if prod, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "product_name"), 128); err == nil { return prod } // Read PCI device ID and look it up if deviceID, err := utils.ReadStringFileLimited(filepath.Join(devicePath, "device"), 64); err == nil { id := normalizeHexID(deviceID) revision := "" if rev, revErr := utils.ReadStringFileLimited(filepath.Join(devicePath, "revision"), 64); revErr == nil { revision = normalizeHexID(rev) } if name, found, done := getCachedAmdgpuName(id, revision); found { return name } else if !done { if name, exact, ok := lookupAmdgpuNameInFile(id, revision, "/usr/share/libdrm/amdgpu.ids"); ok { cacheAmdgpuName(id, revision, name, exact) return normalizeAmdgpuName(name) } cacheMissingAmdgpuName(id, revision) } return fmt.Sprintf("AMD GPU (%s)", id) } return "AMD GPU" } ================================================ FILE: agent/gpu_amd_linux_test.go ================================================ //go:build linux package agent import ( "os" "path/filepath" "testing" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNormalizeHexID(t *testing.T) { tests := []struct { in string want string }{ {"0x1002", "1002"}, {"C2", "c2"}, {" 15BF ", "15bf"}, {"0x15bf", "15bf"}, {"", ""}, } for _, tt := range tests { subName := tt.in if subName == "" { subName = "empty_string" } t.Run(subName, func(t *testing.T) { got := normalizeHexID(tt.in) assert.Equal(t, tt.want, got) }) } } func TestCacheKeyForAmdgpu(t *testing.T) { tests := []struct { deviceID string revisionID string want string }{ {"1114", "c2", "1114:c2"}, {"15bf", "", "15bf"}, {"1506", "c1", "1506:c1"}, } for _, tt := range tests { got := cacheKeyForAmdgpu(tt.deviceID, tt.revisionID) assert.Equal(t, tt.want, got) } } func TestReadSysfsFloat(t *testing.T) { dir := t.TempDir() validPath := filepath.Join(dir, "val") require.NoError(t, os.WriteFile(validPath, []byte(" 42.5 \n"), 0o644)) got, err := readSysfsFloat(validPath) require.NoError(t, err) assert.Equal(t, 42.5, got) // Integer and scientific sciPath := filepath.Join(dir, "sci") require.NoError(t, os.WriteFile(sciPath, []byte("1e2"), 0o644)) got, err = readSysfsFloat(sciPath) require.NoError(t, err) assert.Equal(t, 100.0, got) // Missing file _, err = readSysfsFloat(filepath.Join(dir, "missing")) require.Error(t, err) // Invalid content badPath := filepath.Join(dir, "bad") require.NoError(t, os.WriteFile(badPath, []byte("not a number"), 0o644)) _, err = readSysfsFloat(badPath) require.Error(t, err) } func TestIsAmdGpu(t *testing.T) { dir := t.TempDir() deviceDir := filepath.Join(dir, "device") require.NoError(t, os.MkdirAll(deviceDir, 0o755)) // AMD vendor 0x1002 -> true require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x1002\n"), 0o644)) assert.True(t, isAmdGpu(dir), "vendor 0x1002 should be AMD") // Non-AMD vendor -> false require.NoError(t, os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x10de\n"), 0o644)) assert.False(t, isAmdGpu(dir), "vendor 0x10de should not be AMD") // Missing vendor file -> false require.NoError(t, os.Remove(filepath.Join(deviceDir, "vendor"))) assert.False(t, isAmdGpu(dir), "missing vendor file should be false") } func TestAmdgpuNameCacheRoundTrip(t *testing.T) { // Cache a name and retrieve it (unique key to avoid affecting other tests) deviceID, revisionID := "cachedev99", "00" cacheAmdgpuName(deviceID, revisionID, "AMD Test GPU 99 Graphics", true) name, found, done := getCachedAmdgpuName(deviceID, revisionID) assert.True(t, found) assert.True(t, done) assert.Equal(t, "AMD Test GPU 99", name) // Device-only key also stored name2, found2, _ := getCachedAmdgpuName(deviceID, "") assert.True(t, found2) assert.Equal(t, "AMD Test GPU 99", name2) // Cache a miss cacheMissingAmdgpuName("missedev99", "ab") _, found3, done3 := getCachedAmdgpuName("missedev99", "ab") assert.False(t, found3) assert.True(t, done3, "done should be true so caller skips file lookup") } func TestUpdateAmdGpuDataWithFakeSysfs(t *testing.T) { tests := []struct { name string writeGTT bool wantMemoryUsed float64 wantMemoryTotal float64 }{ { name: "sums vram and gtt when gtt is present", writeGTT: true, wantMemoryUsed: utils.BytesToMegabytes(1073741824 + 536870912), wantMemoryTotal: utils.BytesToMegabytes(2147483648 + 4294967296), }, { name: "falls back to vram when gtt is missing", writeGTT: false, wantMemoryUsed: utils.BytesToMegabytes(1073741824), wantMemoryTotal: utils.BytesToMegabytes(2147483648), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dir := t.TempDir() cardPath := filepath.Join(dir, "card0") devicePath := filepath.Join(cardPath, "device") hwmonPath := filepath.Join(devicePath, "hwmon", "hwmon0") require.NoError(t, os.MkdirAll(hwmonPath, 0o755)) write := func(name, content string) { require.NoError(t, os.WriteFile(filepath.Join(devicePath, name), []byte(content), 0o644)) } write("vendor", "0x1002") write("device", "0x1506") write("revision", "0xc1") write("gpu_busy_percent", "25") write("mem_info_vram_used", "1073741824") write("mem_info_vram_total", "2147483648") if tt.writeGTT { write("mem_info_gtt_used", "536870912") write("mem_info_gtt_total", "4294967296") } require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "temp1_input"), []byte("45000"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(hwmonPath, "power1_input"), []byte("20000000"), 0o644)) // Pre-cache name so getAmdGpuName returns a known value (it uses system amdgpu.ids path) cacheAmdgpuName("1506", "c1", "AMD Radeon 610M Graphics", true) gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)} ok := gm.updateAmdGpuData(cardPath) require.True(t, ok) gpu, ok := gm.GpuDataMap["card0"] require.True(t, ok) assert.Equal(t, "AMD Radeon 610M", gpu.Name) assert.Equal(t, 25.0, gpu.Usage) assert.Equal(t, tt.wantMemoryUsed, gpu.MemoryUsed) assert.Equal(t, tt.wantMemoryTotal, gpu.MemoryTotal) assert.Equal(t, 45.0, gpu.Temperature) assert.Equal(t, 20.0, gpu.Power) assert.Equal(t, 1.0, gpu.Count) }) } } func TestLookupAmdgpuNameInFile(t *testing.T) { idsPath := filepath.Join("test-data", "amdgpu.ids") tests := []struct { name string deviceID string revisionID string wantName string wantExact bool wantFound bool }{ { name: "exact device and revision match", deviceID: "1114", revisionID: "c2", wantName: "AMD Radeon 860M Graphics", wantExact: true, wantFound: true, }, { name: "exact match 15BF revision 01 returns 760M", deviceID: "15bf", revisionID: "01", wantName: "AMD Radeon 760M Graphics", wantExact: true, wantFound: true, }, { name: "exact match 15BF revision 00 returns 780M", deviceID: "15bf", revisionID: "00", wantName: "AMD Radeon 780M Graphics", wantExact: true, wantFound: true, }, { name: "device-only match returns first entry for device", deviceID: "1506", revisionID: "", wantName: "AMD Radeon 610M", wantExact: false, wantFound: true, }, { name: "unknown device not found", deviceID: "dead", revisionID: "00", wantName: "", wantExact: false, wantFound: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotName, gotExact, gotFound := lookupAmdgpuNameInFile(tt.deviceID, tt.revisionID, idsPath) assert.Equal(t, tt.wantName, gotName, "name") assert.Equal(t, tt.wantExact, gotExact, "exact") assert.Equal(t, tt.wantFound, gotFound, "found") }) } } func TestGetAmdGpuNameFromIdsFile(t *testing.T) { // Test that getAmdGpuName resolves a name when we can't inject the ids path. // We only verify behavior when product_name is missing and device/revision // would be read from sysfs; the actual lookup uses /usr/share/libdrm/amdgpu.ids. // So this test focuses on normalizeAmdgpuName and that lookupAmdgpuNameInFile // returns the expected name for our test-data file. idsPath := filepath.Join("test-data", "amdgpu.ids") name, exact, found := lookupAmdgpuNameInFile("1435", "ae", idsPath) require.True(t, found) require.True(t, exact) assert.Equal(t, "AMD Custom GPU 0932", name) assert.Equal(t, "AMD Custom GPU 0932", normalizeAmdgpuName(name)) // " Graphics" suffix is trimmed by normalizeAmdgpuName name2 := "AMD Radeon 860M Graphics" assert.Equal(t, "AMD Radeon 860M", normalizeAmdgpuName(name2)) } ================================================ FILE: agent/gpu_amd_unsupported.go ================================================ //go:build !linux package agent import ( "errors" ) func (gm *GPUManager) hasAmdSysfs() bool { return false } func (gm *GPUManager) collectAmdStats() error { return errors.ErrUnsupported } ================================================ FILE: agent/gpu_darwin.go ================================================ //go:build darwin package agent import ( "bufio" "bytes" "encoding/json" "io" "log/slog" "os/exec" "strconv" "strings" "time" "github.com/henrygd/beszel/internal/entities/system" ) const ( // powermetricsSampleIntervalMs is the sampling interval passed to powermetrics (-i). powermetricsSampleIntervalMs = 500 // powermetricsPollInterval is how often we run powermetrics to collect a new sample. powermetricsPollInterval = 2 * time.Second // macmonIntervalMs is the sampling interval passed to macmon pipe (-i), in milliseconds. macmonIntervalMs = 2500 ) const appleGPUID = "0" // startPowermetricsCollector runs powermetrics --samplers gpu_power in a loop and updates // GPU usage and power. Requires root (sudo) on macOS. A single logical GPU is reported as id "0". func (gm *GPUManager) startPowermetricsCollector() { // Ensure single GPU entry for Apple GPU if _, ok := gm.GpuDataMap[appleGPUID]; !ok { gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"} } go func() { failures := 0 for { if err := gm.collectPowermetrics(); err != nil { failures++ if failures > maxFailureRetries { slog.Warn("powermetrics GPU collector failed repeatedly, stopping", "err", err) break } slog.Warn("Error collecting macOS GPU data via powermetrics (may require sudo)", "err", err) time.Sleep(retryWaitTime) continue } failures = 0 time.Sleep(powermetricsPollInterval) } }() } // collectPowermetrics runs powermetrics once and parses GPU usage and power from its output. func (gm *GPUManager) collectPowermetrics() error { interval := strconv.Itoa(powermetricsSampleIntervalMs) cmd := exec.Command(powermetricsCmd, "--samplers", "gpu_power", "-i", interval, "-n", "1") cmd.Stderr = nil out, err := cmd.Output() if err != nil { return err } if !gm.parsePowermetricsData(out) { return errNoValidData } return nil } // parsePowermetricsData parses powermetrics gpu_power output and updates GpuDataMap["0"]. // Example output: // // **** GPU usage **** // GPU HW active frequency: 444 MHz // GPU HW active residency: 0.97% (444 MHz: .97% ... // GPU idle residency: 99.03% // GPU Power: 4 mW func (gm *GPUManager) parsePowermetricsData(output []byte) bool { var idleResidency, powerMW float64 var gotIdle, gotPower bool scanner := bufio.NewScanner(bytes.NewReader(output)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "GPU idle residency:") { // "GPU idle residency: 99.03%" fields := strings.Fields(strings.TrimPrefix(line, "GPU idle residency:")) if len(fields) >= 1 { pct := strings.TrimSuffix(fields[0], "%") if v, err := strconv.ParseFloat(pct, 64); err == nil { idleResidency = v gotIdle = true } } } else if strings.HasPrefix(line, "GPU Power:") { // "GPU Power: 4 mW" fields := strings.Fields(strings.TrimPrefix(line, "GPU Power:")) if len(fields) >= 1 { if v, err := strconv.ParseFloat(fields[0], 64); err == nil { powerMW = v gotPower = true } } } } if err := scanner.Err(); err != nil { return false } if !gotIdle && !gotPower { return false } gm.Lock() defer gm.Unlock() if _, ok := gm.GpuDataMap[appleGPUID]; !ok { gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"} } gpu := gm.GpuDataMap[appleGPUID] if gotIdle { // Usage = 100 - idle residency (e.g. 100 - 99.03 = 0.97%) gpu.Usage += 100 - idleResidency } if gotPower { // mW -> W gpu.Power += powerMW / milliwattsInAWatt } gpu.Count++ return true } // startMacmonCollector runs `macmon pipe` in a loop and parses one JSON object per line. // This collector does not require sudo. A single logical GPU is reported as id "0". func (gm *GPUManager) startMacmonCollector() { if _, ok := gm.GpuDataMap[appleGPUID]; !ok { gm.GpuDataMap[appleGPUID] = &system.GPUData{Name: "Apple GPU"} } go func() { failures := 0 for { if err := gm.collectMacmonPipe(); err != nil { failures++ if failures > maxFailureRetries { slog.Warn("macmon GPU collector failed repeatedly, stopping", "err", err) break } slog.Warn("Error collecting macOS GPU data via macmon", "err", err) time.Sleep(retryWaitTime) continue } failures = 0 // `macmon pipe` is long-running; if it returns, wait a bit before restarting. time.Sleep(retryWaitTime) } }() } type macmonTemp struct { GPUTempAvg float64 `json:"gpu_temp_avg"` } type macmonSample struct { GPUPower float64 `json:"gpu_power"` // watts (macmon reports fractional values) GPURAMPower float64 `json:"gpu_ram_power"` // watts GPUUsage []float64 `json:"gpu_usage"` // [freq_mhz, usage] where usage is typically 0..1 Temp macmonTemp `json:"temp"` } func (gm *GPUManager) collectMacmonPipe() (err error) { cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs)) // Avoid blocking if macmon writes to stderr. cmd.Stderr = io.Discard stdout, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } // Ensure we always reap the child to avoid zombies on any return path and // propagate a non-zero exit code if no other error was set. defer func() { _ = stdout.Close() if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { _ = cmd.Process.Kill() } if waitErr := cmd.Wait(); err == nil && waitErr != nil { err = waitErr } }() scanner := bufio.NewScanner(stdout) var hadSample bool for scanner.Scan() { line := bytes.TrimSpace(scanner.Bytes()) if len(line) == 0 { continue } if gm.parseMacmonLine(line) { hadSample = true } } if scanErr := scanner.Err(); scanErr != nil { return scanErr } if !hadSample { return errNoValidData } return nil } // parseMacmonLine parses a single macmon JSON line and updates Apple GPU metrics. func (gm *GPUManager) parseMacmonLine(line []byte) bool { var sample macmonSample if err := json.Unmarshal(line, &sample); err != nil { return false } usage := 0.0 if len(sample.GPUUsage) >= 2 { usage = sample.GPUUsage[1] // Heuristic: macmon typically reports 0..1; convert to percentage. if usage <= 1.0 { usage *= 100 } } // Consider the line valid if it contains at least one GPU metric. if usage == 0 && sample.GPUPower == 0 && sample.Temp.GPUTempAvg == 0 { return false } gm.Lock() defer gm.Unlock() gpu, ok := gm.GpuDataMap[appleGPUID] if !ok { gpu = &system.GPUData{Name: "Apple GPU"} gm.GpuDataMap[appleGPUID] = gpu } gpu.Temperature = sample.Temp.GPUTempAvg gpu.Usage += usage // macmon reports power in watts; include VRAM power if present. gpu.Power += sample.GPUPower + sample.GPURAMPower gpu.Count++ return true } ================================================ FILE: agent/gpu_darwin_test.go ================================================ //go:build darwin package agent import ( "testing" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParsePowermetricsData(t *testing.T) { input := ` Machine model: Mac14,10 OS version: 25D125 *** Sampled system activity (Sat Feb 14 00:42:06 2026 -0500) (503.05ms elapsed) *** **** GPU usage **** GPU HW active frequency: 444 MHz GPU HW active residency: 0.97% (444 MHz: .97% 612 MHz: 0% 808 MHz: 0% 968 MHz: 0% 1110 MHz: 0% 1236 MHz: 0% 1338 MHz: 0% 1398 MHz: 0%) GPU SW requested state: (P1 : 100% P2 : 0% P3 : 0% P4 : 0% P5 : 0% P6 : 0% P7 : 0% P8 : 0%) GPU idle residency: 99.03% GPU Power: 4 mW ` gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parsePowermetricsData([]byte(input)) require.True(t, valid) g0, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "Apple GPU", g0.Name) // Usage = 100 - 99.03 = 0.97 assert.InDelta(t, 0.97, g0.Usage, 0.01) // 4 mW -> 0.004 W assert.InDelta(t, 0.004, g0.Power, 0.0001) assert.Equal(t, 1.0, g0.Count) } func TestParsePowermetricsDataPartial(t *testing.T) { // Only power line (e.g. older macOS or different sampler output) input := ` **** GPU usage **** GPU Power: 120 mW ` gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parsePowermetricsData([]byte(input)) require.True(t, valid) g0, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "Apple GPU", g0.Name) assert.InDelta(t, 0.12, g0.Power, 0.001) assert.Equal(t, 1.0, g0.Count) } func TestParseMacmonLine(t *testing.T) { input := `{"all_power":0.6468324661254883,"ane_power":0.0,"cpu_power":0.6359732151031494,"ecpu_usage":[2061,0.1726151406764984],"gpu_power":0.010859241709113121,"gpu_ram_power":0.000965250947047025,"gpu_usage":[503,0.013633215799927711],"memory":{"ram_total":17179869184,"ram_usage":12322914304,"swap_total":0,"swap_usage":0},"pcpu_usage":[1248,0.11792058497667313],"ram_power":0.14885640144348145,"sys_power":10.4955415725708,"temp":{"cpu_temp_avg":23.041261672973633,"gpu_temp_avg":29.44516944885254},"timestamp":"2026-02-17T19:34:27.942556+00:00"}` gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parseMacmonLine([]byte(input)) require.True(t, valid) g0, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "Apple GPU", g0.Name) // macmon reports usage fraction 0..1; expect percent conversion. assert.InDelta(t, 1.3633, g0.Usage, 0.05) // power includes gpu_power + gpu_ram_power assert.InDelta(t, 0.011824, g0.Power, 0.0005) assert.InDelta(t, 29.445, g0.Temperature, 0.01) assert.Equal(t, 1.0, g0.Count) } ================================================ FILE: agent/gpu_darwin_unsupported.go ================================================ //go:build !darwin package agent // startPowermetricsCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go. func (gm *GPUManager) startPowermetricsCollector() {} // startMacmonCollector is a no-op on non-darwin platforms; the real implementation is in gpu_darwin.go. func (gm *GPUManager) startMacmonCollector() {} ================================================ FILE: agent/gpu_intel.go ================================================ package agent import ( "bufio" "io" "os/exec" "strconv" "strings" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) const ( intelGpuStatsCmd string = "intel_gpu_top" intelGpuStatsInterval string = "3300" // in milliseconds ) type intelGpuStats struct { PowerGPU float64 PowerPkg float64 Engines map[string]float64 } // updateIntelFromStats updates aggregated GPU data from a single intelGpuStats sample func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool { gm.Lock() defer gm.Unlock() // only one gpu for now - cmd doesn't provide all by default id := "i0" // prefix with i to avoid conflicts with nvidia card ids gpuData, ok := gm.GpuDataMap[id] if !ok { gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)} gm.GpuDataMap[id] = gpuData } gpuData.Power += sample.PowerGPU gpuData.PowerPkg += sample.PowerPkg if gpuData.Engines == nil { gpuData.Engines = make(map[string]float64, len(sample.Engines)) } for name, engine := range sample.Engines { gpuData.Engines[name] += engine } gpuData.Count++ return true } // collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output func (gm *GPUManager) collectIntelStats() (err error) { // Build command arguments, optionally selecting a device via -d args := []string{"-s", intelGpuStatsInterval, "-l"} if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" { args = append(args, "-d", dev) } cmd := exec.Command(intelGpuStatsCmd, args...) // Avoid blocking if intel_gpu_top writes to stderr cmd.Stderr = io.Discard stdout, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } // Ensure we always reap the child to avoid zombies on any return path and // propagate a non-zero exit code if no other error was set. defer func() { // Best-effort close of the pipe (unblock the child if it writes) _ = stdout.Close() if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { _ = cmd.Process.Kill() } if waitErr := cmd.Wait(); err == nil && waitErr != nil { err = waitErr } }() scanner := bufio.NewScanner(stdout) var header1 string var engineNames []string var friendlyNames []string var preEngineCols int var powerIndex int var hadDataRow bool // skip first data row because it sometimes has erroneous data var skippedFirstDataRow bool for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // first header line if strings.HasPrefix(line, "Freq") { header1 = line continue } // second header line if strings.HasPrefix(line, "req") { engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line) continue } // Data row if !skippedFirstDataRow { skippedFirstDataRow = true continue } sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols) if err != nil { return err } hadDataRow = true gm.updateIntelFromStats(&sample) } if scanErr := scanner.Err(); scanErr != nil { return scanErr } if !hadDataRow { return errNoValidData } return nil } func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) { // Build indexes h1 := strings.Fields(header1) h2 := strings.Fields(header2) powerIndex = -1 // Initialize to -1, will be set to actual index if found // Collect engine names from header1 for _, col := range h1 { key := strings.TrimRightFunc(col, func(r rune) bool { return (r >= '0' && r <= '9') || r == '/' }) var friendly string switch key { case "RCS": friendly = "Render/3D" case "BCS": friendly = "Blitter" case "VCS": friendly = "Video" case "VECS": friendly = "VideoEnhance" case "CCS": friendly = "Compute" default: continue } engineNames = append(engineNames, key) friendlyNames = append(friendlyNames, friendly) } // find power gpu index among pre-engine columns if n := len(engineNames); n > 0 { preEngineCols = max(len(h2)-3*n, 0) limit := min(len(h2), preEngineCols) for i := range limit { if strings.EqualFold(h2[i], "gpu") { powerIndex = i break } } } return engineNames, friendlyNames, powerIndex, preEngineCols } func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) { fields := strings.Fields(line) if len(fields) == 0 { return sample, errNoValidData } // Make sure row has enough columns for engines if need := preEngineCols + 3*len(engineNames); len(fields) < need { return sample, errNoValidData } if powerIndex >= 0 && powerIndex < len(fields) { if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil { sample.PowerGPU = v } if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil { sample.PowerPkg = v } } if len(engineNames) > 0 { sample.Engines = make(map[string]float64, len(engineNames)) for k := range engineNames { base := preEngineCols + 3*k if base < len(fields) { busy := 0.0 if v, e := strconv.ParseFloat(fields[base], 64); e == nil { busy = v } cur := sample.Engines[friendlyNames[k]] sample.Engines[friendlyNames[k]] = cur + busy } else { sample.Engines[friendlyNames[k]] = 0 } } } return sample, nil } ================================================ FILE: agent/gpu_nvml.go ================================================ //go:build amd64 && (windows || (linux && glibc)) package agent import ( "fmt" "log/slog" "strings" "time" "unsafe" "github.com/ebitengine/purego" "github.com/henrygd/beszel/internal/entities/system" ) // NVML constants and types const ( nvmlSuccess int = 0 ) type nvmlDevice uintptr type nvmlReturn int type nvmlMemoryV1 struct { Total uint64 Free uint64 Used uint64 } type nvmlMemoryV2 struct { Version uint32 Total uint64 Reserved uint64 Free uint64 Used uint64 } type nvmlUtilization struct { Gpu uint32 Memory uint32 } type nvmlPciInfo struct { BusId [16]byte Domain uint32 Bus uint32 Device uint32 PciDeviceId uint32 PciSubSystemId uint32 } // NVML function signatures var ( nvmlInit func() nvmlReturn nvmlShutdown func() nvmlReturn nvmlDeviceGetCount func(count *uint32) nvmlReturn nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn nvmlErrorString func(result nvmlReturn) string ) type nvmlCollector struct { gm *GPUManager lib uintptr devices []nvmlDevice bdfs []string isV2 bool } func (c *nvmlCollector) init() error { slog.Debug("NVML: Initializing") libPath := getNVMLPath() lib, err := openLibrary(libPath) if err != nil { return fmt.Errorf("failed to load %s: %w", libPath, err) } c.lib = lib purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit") purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown") purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount") purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex") purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName") // Try to get v2 memory info, fallback to v1 if not available if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") { c.isV2 = true purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2") } else { purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo") } purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates") purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature") purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage") purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo") purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString") if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) { return fmt.Errorf("nvmlInit failed: %v", ret) } var count uint32 if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) { return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret) } for i := uint32(0); i < count; i++ { var device nvmlDevice if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) { c.devices = append(c.devices, device) // Get BDF for power state check var pci nvmlPciInfo if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) { busID := string(pci.BusId[:]) if idx := strings.Index(busID, "\x00"); idx != -1 { busID = busID[:idx] } c.bdfs = append(c.bdfs, strings.ToLower(busID)) } else { c.bdfs = append(c.bdfs, "") } } } return nil } func (c *nvmlCollector) start() { defer nvmlShutdown() ticker := time.Tick(3 * time.Second) for range ticker { c.collect() } } func (c *nvmlCollector) collect() { c.gm.Lock() defer c.gm.Unlock() for i, device := range c.devices { id := fmt.Sprintf("%d", i) bdf := c.bdfs[i] // Update GPUDataMap if _, ok := c.gm.GpuDataMap[id]; !ok { var nameBuf [64]byte if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) { continue } name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")]) name = strings.TrimPrefix(name, "NVIDIA ") c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")} } gpu := c.gm.GpuDataMap[id] if bdf != "" && !c.isGPUActive(bdf) { slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf) gpu.Temperature = 0 gpu.MemoryUsed = 0 continue } // Utilization var utilization nvmlUtilization if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) { slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret) gpu.Temperature = 0 gpu.MemoryUsed = 0 continue } slog.Debug("NVML: Collecting data for GPU", "bdf", bdf) // Temperature var temp uint32 nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU // Memory: only poll if GPU is active to avoid leaving D3cold state (#1522) if utilization.Gpu > 0 { var usedMem, totalMem uint64 if c.isV2 { var memory nvmlMemoryV2 memory.Version = 0x02000028 // (2 << 24) | 40 bytes if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) { slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret) } else { usedMem = memory.Used totalMem = memory.Total } } else { var memory nvmlMemoryV1 if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) { slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret) } else { usedMem = memory.Used totalMem = memory.Total } } if totalMem > 0 { gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte } } else { slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf) } // Power var power uint32 nvmlDeviceGetPowerUsage(device, &power) gpu.Temperature = float64(temp) gpu.Usage += float64(utilization.Gpu) gpu.Power += float64(power) / 1000.0 gpu.Count++ slog.Debug("NVML: Collected data", "gpu", gpu) } } ================================================ FILE: agent/gpu_nvml_linux.go ================================================ //go:build glibc && linux && amd64 package agent import ( "log/slog" "os" "path/filepath" "strings" "github.com/ebitengine/purego" ) func openLibrary(name string) (uintptr, error) { return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL) } func getNVMLPath() string { return "libnvidia-ml.so.1" } func hasSymbol(lib uintptr, symbol string) bool { _, err := purego.Dlsym(lib, symbol) return err == nil } func (c *nvmlCollector) isGPUActive(bdf string) bool { // runtime_status statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status") status, err := os.ReadFile(statusPath) if err != nil { slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err) return true // Assume active if we can't read status } statusStr := strings.TrimSpace(string(status)) if statusStr != "active" && statusStr != "resuming" { slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr) return false } // power_state (D0 check) // Find any drm card device power_state pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state") matches, _ := filepath.Glob(pstatePathPattern) if len(matches) > 0 { pstate, err := os.ReadFile(matches[0]) if err == nil { pstateStr := strings.TrimSpace(string(pstate)) if pstateStr != "D0" { slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr) return false } } } return true } ================================================ FILE: agent/gpu_nvml_unsupported.go ================================================ //go:build (!linux && !windows) || !amd64 || (linux && !glibc) package agent import "fmt" type nvmlCollector struct { gm *GPUManager } func (c *nvmlCollector) init() error { return fmt.Errorf("nvml not supported on this platform") } func (c *nvmlCollector) start() {} ================================================ FILE: agent/gpu_nvml_windows.go ================================================ //go:build windows && amd64 package agent import ( "golang.org/x/sys/windows" ) func openLibrary(name string) (uintptr, error) { handle, err := windows.LoadLibrary(name) return uintptr(handle), err } func getNVMLPath() string { return "nvml.dll" } func hasSymbol(lib uintptr, symbol string) bool { _, err := windows.GetProcAddress(windows.Handle(lib), symbol) return err == nil } func (c *nvmlCollector) isGPUActive(bdf string) bool { return true } ================================================ FILE: agent/gpu_nvtop.go ================================================ package agent import ( "encoding/json" "io" "log/slog" "os/exec" "strconv" "strings" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" ) type nvtopSnapshot struct { DeviceName string `json:"device_name"` Temp *string `json:"temp"` PowerDraw *string `json:"power_draw"` GpuUtil *string `json:"gpu_util"` MemTotal *string `json:"mem_total"` MemUsed *string `json:"mem_used"` } // parseNvtopNumber parses nvtop numeric strings with units (C/W/%). func parseNvtopNumber(raw string) float64 { cleaned := strings.TrimSpace(raw) cleaned = strings.TrimSuffix(cleaned, "C") cleaned = strings.TrimSuffix(cleaned, "W") cleaned = strings.TrimSuffix(cleaned, "%") val, _ := strconv.ParseFloat(cleaned, 64) return val } // parseNvtopData parses a single nvtop JSON snapshot payload. func (gm *GPUManager) parseNvtopData(output []byte) bool { var snapshots []nvtopSnapshot if err := json.Unmarshal(output, &snapshots); err != nil || len(snapshots) == 0 { return false } return gm.updateNvtopSnapshots(snapshots) } // updateNvtopSnapshots applies one decoded nvtop snapshot batch to GPU accumulators. func (gm *GPUManager) updateNvtopSnapshots(snapshots []nvtopSnapshot) bool { gm.Lock() defer gm.Unlock() valid := false usedIDs := make(map[string]struct{}, len(snapshots)) for i, sample := range snapshots { if sample.DeviceName == "" { continue } indexID := "n" + strconv.Itoa(i) id := indexID // nvtop ordering can change, so prefer reusing an existing slot with matching device name. if existingByIndex, ok := gm.GpuDataMap[indexID]; ok && existingByIndex.Name != "" && existingByIndex.Name != sample.DeviceName { for existingID, gpu := range gm.GpuDataMap { if !strings.HasPrefix(existingID, "n") { continue } if _, taken := usedIDs[existingID]; taken { continue } if gpu.Name == sample.DeviceName { id = existingID break } } } if _, ok := gm.GpuDataMap[id]; !ok { gm.GpuDataMap[id] = &system.GPUData{Name: sample.DeviceName} } gpu := gm.GpuDataMap[id] gpu.Name = sample.DeviceName if sample.Temp != nil { gpu.Temperature = parseNvtopNumber(*sample.Temp) } if sample.MemUsed != nil { gpu.MemoryUsed = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemUsed)) } if sample.MemTotal != nil { gpu.MemoryTotal = utils.BytesToMegabytes(parseNvtopNumber(*sample.MemTotal)) } if sample.GpuUtil != nil { gpu.Usage += parseNvtopNumber(*sample.GpuUtil) } if sample.PowerDraw != nil { gpu.Power += parseNvtopNumber(*sample.PowerDraw) } gpu.Count++ usedIDs[id] = struct{}{} valid = true } return valid } // collectNvtopStats runs nvtop loop mode and continuously decodes JSON snapshots. func (gm *GPUManager) collectNvtopStats(interval string) error { cmd := exec.Command(nvtopCmd, "-lP", "-d", interval) stdout, err := cmd.StdoutPipe() if err != nil { return err } if err := cmd.Start(); err != nil { return err } defer func() { _ = stdout.Close() if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { _ = cmd.Process.Kill() } _ = cmd.Wait() }() decoder := json.NewDecoder(stdout) foundValid := false for { var snapshots []nvtopSnapshot if err := decoder.Decode(&snapshots); err != nil { if err == io.EOF { if foundValid { return nil } return errNoValidData } return err } if gm.updateNvtopSnapshots(snapshots) { foundValid = true } } } // startNvtopCollector starts nvtop collection with retry or fallback callback handling. func (gm *GPUManager) startNvtopCollector(interval string, onFailure func()) { go func() { failures := 0 for { if err := gm.collectNvtopStats(interval); err != nil { if onFailure != nil { slog.Warn("Error collecting GPU data via nvtop", "err", err) onFailure() return } failures++ if failures > maxFailureRetries { break } slog.Warn("Error collecting GPU data via nvtop", "err", err) time.Sleep(retryWaitTime) continue } } }() } ================================================ FILE: agent/gpu_test.go ================================================ //go:build testing package agent import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseNvidiaData(t *testing.T) { tests := []struct { name string input string wantData map[string]system.GPUData wantValid bool }{ { name: "valid multi-gpu data", input: "0, NVIDIA GeForce RTX 3050 Ti Laptop GPU, 48, 12, 4096, 26.3, 12.73\n1, NVIDIA A100-PCIE-40GB, 38, 74, 40960, [N/A], 36.79", wantData: map[string]system.GPUData{ "0": { Name: "GeForce RTX 3050 Ti", Temperature: 48.0, MemoryUsed: 12.0 / 1.024, MemoryTotal: 4096.0 / 1.024, Usage: 26.3, Power: 12.73, Count: 1, }, "1": { Name: "A100-PCIE-40GB", Temperature: 38.0, MemoryUsed: 74.0 / 1.024, MemoryTotal: 40960.0 / 1.024, Usage: 0.0, Power: 36.79, Count: 1, }, }, wantValid: true, }, { name: "more valid multi-gpu data", input: `0, NVIDIA A10, 45, 19676, 23028, 0, 58.98 1, NVIDIA A10, 45, 19638, 23028, 0, 62.35 2, NVIDIA A10, 44, 21700, 23028, 0, 59.57 3, NVIDIA A10, 45, 18222, 23028, 0, 61.76`, wantData: map[string]system.GPUData{ "0": { Name: "A10", Temperature: 45.0, MemoryUsed: 19676.0 / 1.024, MemoryTotal: 23028.0 / 1.024, Usage: 0.0, Power: 58.98, Count: 1, }, "1": { Name: "A10", Temperature: 45.0, MemoryUsed: 19638.0 / 1.024, MemoryTotal: 23028.0 / 1.024, Usage: 0.0, Power: 62.35, Count: 1, }, "2": { Name: "A10", Temperature: 44.0, MemoryUsed: 21700.0 / 1.024, MemoryTotal: 23028.0 / 1.024, Usage: 0.0, Power: 59.57, Count: 1, }, "3": { Name: "A10", Temperature: 45.0, MemoryUsed: 18222.0 / 1.024, MemoryTotal: 23028.0 / 1.024, Usage: 0.0, Power: 61.76, Count: 1, }, }, wantValid: true, }, { name: "empty input", input: "", wantData: map[string]system.GPUData{}, wantValid: false, }, { name: "malformed data", input: "bad, data, here", wantData: map[string]system.GPUData{}, wantValid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parseNvidiaData([]byte(tt.input)) assert.Equal(t, tt.wantValid, valid) if tt.wantValid { for id, want := range tt.wantData { got := gm.GpuDataMap[id] require.NotNil(t, got) assert.Equal(t, want.Name, got.Name) assert.InDelta(t, want.Temperature, got.Temperature, 0.01) assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01) assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01) assert.InDelta(t, want.Usage, got.Usage, 0.01) assert.InDelta(t, want.Power, got.Power, 0.01) assert.Equal(t, want.Count, got.Count) } } }) } } func TestParseAmdData(t *testing.T) { tests := []struct { name string input string wantData map[string]system.GPUData wantValid bool }{ { name: "valid single gpu data", input: `{ "card0": { "GUID": "34756", "Temperature (Sensor edge) (C)": "47.0", "Current Socket Graphics Package Power (W)": "9.215", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "482263040", "Card Series": "Rembrandt [Radeon 680M]" } }`, wantData: map[string]system.GPUData{ "34756": { Name: "Rembrandt [Radeon 680M]", Temperature: 47.0, MemoryUsed: 482263040.0 / (1024 * 1024), MemoryTotal: 536870912.0 / (1024 * 1024), Usage: 0.0, Power: 9.215, Count: 1, }, }, wantValid: true, }, { name: "valid multi gpu data", input: `{ "card0": { "GUID": "34756", "Temperature (Sensor edge) (C)": "47.0", "Current Socket Graphics Package Power (W)": "9.215", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "482263040", "Card Series": "Rembrandt [Radeon 680M]" }, "card1": { "GUID": "38294", "Temperature (Sensor edge) (C)": "49.0", "Temperature (Sensor junction) (C)": "49.0", "Temperature (Sensor memory) (C)": "62.0", "Average Graphics Package Power (W)": "19.0", "GPU use (%)": "20.3", "VRAM Total Memory (B)": "25753026560", "VRAM Total Used Memory (B)": "794341376", "Card Series": "Navi 31 [Radeon RX 7900 XT]" } }`, wantData: map[string]system.GPUData{ "34756": { Name: "Rembrandt [Radeon 680M]", Temperature: 47.0, MemoryUsed: 482263040.0 / (1024 * 1024), MemoryTotal: 536870912.0 / (1024 * 1024), Usage: 0.0, Power: 9.215, Count: 1, }, "38294": { Name: "Navi 31 [Radeon RX 7900 XT]", Temperature: 49.0, MemoryUsed: 794341376.0 / (1024 * 1024), MemoryTotal: 25753026560.0 / (1024 * 1024), Usage: 20.3, Power: 19.0, Count: 1, }, }, wantValid: true, }, { name: "invalid json", input: "{bad json", }, { name: "invalid json", input: "{bad json", wantData: map[string]system.GPUData{}, wantValid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parseAmdData([]byte(tt.input)) assert.Equal(t, tt.wantValid, valid) if tt.wantValid { for id, want := range tt.wantData { got := gm.GpuDataMap[id] require.NotNil(t, got) assert.Equal(t, want.Name, got.Name) assert.InDelta(t, want.Temperature, got.Temperature, 0.01) assert.InDelta(t, want.MemoryUsed, got.MemoryUsed, 0.01) assert.InDelta(t, want.MemoryTotal, got.MemoryTotal, 0.01) assert.InDelta(t, want.Usage, got.Usage, 0.01) assert.InDelta(t, want.Power, got.Power, 0.01) assert.Equal(t, want.Count, got.Count) } } }) } } func TestParseNvtopData(t *testing.T) { input, err := os.ReadFile("test-data/nvtop.json") require.NoError(t, err) gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } valid := gm.parseNvtopData(input) require.True(t, valid) g0, ok := gm.GpuDataMap["n0"] require.True(t, ok) assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", g0.Name) assert.Equal(t, 48.0, g0.Temperature) assert.Equal(t, 5.0, g0.Usage) assert.Equal(t, 13.0, g0.Power) assert.Equal(t, utils.BytesToMegabytes(349372416), g0.MemoryUsed) assert.Equal(t, utils.BytesToMegabytes(4294967296), g0.MemoryTotal) assert.Equal(t, 1.0, g0.Count) g1, ok := gm.GpuDataMap["n1"] require.True(t, ok) assert.Equal(t, "AMD Radeon 680M", g1.Name) assert.Equal(t, 48.0, g1.Temperature) assert.Equal(t, 12.0, g1.Usage) assert.Equal(t, 9.0, g1.Power) assert.Equal(t, utils.BytesToMegabytes(1213784064), g1.MemoryUsed) assert.Equal(t, utils.BytesToMegabytes(16929173504), g1.MemoryTotal) assert.Equal(t, 1.0, g1.Count) } func TestUpdateNvtopSnapshotsKeepsDeviceAssociationWhenOrderChanges(t *testing.T) { strPtr := func(s string) *string { return &s } gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } firstBatch := []nvtopSnapshot{ { DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU", GpuUtil: strPtr("20%"), PowerDraw: strPtr("10W"), }, { DeviceName: "AMD Radeon 680M", GpuUtil: strPtr("30%"), PowerDraw: strPtr("20W"), }, } secondBatchSwapped := []nvtopSnapshot{ { DeviceName: "AMD Radeon 680M", GpuUtil: strPtr("40%"), PowerDraw: strPtr("25W"), }, { DeviceName: "NVIDIA GeForce RTX 3050 Ti Laptop GPU", GpuUtil: strPtr("50%"), PowerDraw: strPtr("15W"), }, } require.True(t, gm.updateNvtopSnapshots(firstBatch)) require.True(t, gm.updateNvtopSnapshots(secondBatchSwapped)) nvidia := gm.GpuDataMap["n0"] require.NotNil(t, nvidia) assert.Equal(t, "NVIDIA GeForce RTX 3050 Ti Laptop GPU", nvidia.Name) assert.Equal(t, 70.0, nvidia.Usage) assert.Equal(t, 25.0, nvidia.Power) assert.Equal(t, 2.0, nvidia.Count) amd := gm.GpuDataMap["n1"] require.NotNil(t, amd) assert.Equal(t, "AMD Radeon 680M", amd.Name) assert.Equal(t, 70.0, amd.Usage) assert.Equal(t, 45.0, amd.Power) assert.Equal(t, 2.0, amd.Count) } func TestParseCollectorPriority(t *testing.T) { got := parseCollectorPriority(" nvml, nvidia-smi, intel_gpu_top, amd_sysfs, nvtop, rocm-smi, bad ") want := []collectorSource{ collectorSourceNVML, collectorSourceNvidiaSMI, collectorSourceIntelGpuTop, collectorSourceAmdSysfs, collectorSourceNVTop, collectorSourceRocmSMI, } assert.Equal(t, want, got) } func TestParseJetsonData(t *testing.T) { tests := []struct { name string input string 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: "GPU", MemoryUsed: 4300.0, MemoryTotal: 30698.0, Usage: 45.0, Temperature: 52.468, Power: 2.171, Count: 1, }, }, { 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: "GPU", MemoryUsed: 6185.0, MemoryTotal: 7620.0, Usage: 63.0, Temperature: 53.968, Power: 4.667, Count: 1, }, }, { name: "orin nano", input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW", wantMetrics: &system.GPUData{ Name: "GPU", MemoryUsed: 3452.0, MemoryTotal: 7620.0, Usage: 0.0, Temperature: 50.25, Power: 0.518, Count: 1, }, }, { name: "missing temperature", input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW", wantMetrics: &system.GPUData{ Name: "GPU", MemoryUsed: 4300.0, MemoryTotal: 30698.0, Usage: 45.0, Power: 2.171, Count: 1, }, }, { name: "orin-style output with GPU@ temp and VDD_SYS_GPU power", input: "RAM 3276/7859MB (lfb 5x4MB) SWAP 1626/12122MB (cached 181MB) CPU [44%@1421,49%@2031,67%@2034,17%@1420,25%@1419,8%@1420] EMC_FREQ 1%@1866 GR3D_FREQ 0%@114 APE 150 MTS fg 1% bg 1% PLL@42.5C MCPU@42.5C PMIC@50C Tboard@38C GPU@39.5C BCPU@42.5C thermal@41.3C Tdiode@39.25C VDD_SYS_GPU 182/182 VDD_SYS_SOC 730/730 VDD_4V0_WIFI 0/0 VDD_IN 5297/5297 VDD_SYS_CPU 1917/1917 VDD_SYS_DDR 1241/1241", wantMetrics: &system.GPUData{ Name: "GPU", MemoryUsed: 3276.0, MemoryTotal: 7859.0, Usage: 0.0, Power: 0.182, // 182mW -> 0.182W Temperature: 39.5, Count: 1, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } parser := gm.getJetsonParser() valid := parser([]byte(tt.input)) assert.Equal(t, true, valid) 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) assert.InDelta(t, tt.wantMetrics.MemoryTotal, got.MemoryTotal, 0.01) assert.InDelta(t, tt.wantMetrics.Usage, got.Usage, 0.01) if tt.wantMetrics.Temperature > 0 { assert.InDelta(t, tt.wantMetrics.Temperature, got.Temperature, 0.01) } assert.InDelta(t, tt.wantMetrics.Power, got.Power, 0.01) assert.Equal(t, tt.wantMetrics.Count, got.Count) }) } } func TestGetCurrentData(t *testing.T) { t.Run("calculates averages with per-cache-key delta tracking", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": { Name: "GPU1", Temperature: 50, MemoryUsed: 2048, MemoryTotal: 4096, Usage: 100, // 100 over 2 counts = 50 avg Power: 200, // 200 over 2 counts = 100 avg Count: 2, }, "1": { Name: "GPU1", Temperature: 60, MemoryUsed: 3072, MemoryTotal: 8192, Usage: 30, Power: 60, Count: 1, }, "2": { Name: "GPU 2", Temperature: 70, MemoryUsed: 4096, MemoryTotal: 8192, Usage: 200, Power: 400, Count: 1, }, }, } cacheKey := uint16(5000) result := gm.GetCurrentData(cacheKey) // Verify name disambiguation assert.Equal(t, "GPU1 0", result["0"].Name) assert.Equal(t, "GPU1 1", result["1"].Name) assert.Equal(t, "GPU 2", result["2"].Name) // Check averaged values in the result assert.InDelta(t, 50.0, result["0"].Usage, 0.01) assert.InDelta(t, 100.0, result["0"].Power, 0.01) assert.InDelta(t, 30.0, result["1"].Usage, 0.01) assert.InDelta(t, 60.0, result["1"].Power, 0.01) // Verify that accumulators in the original map are NOT reset (they keep growing) assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "GPU 0 Count should remain at 2") assert.EqualValues(t, 100, gm.GpuDataMap["0"].Usage, "GPU 0 Usage should remain at 100") assert.Equal(t, 200.0, gm.GpuDataMap["0"].Power, "GPU 0 Power should remain at 200") assert.Equal(t, 1.0, gm.GpuDataMap["1"].Count, "GPU 1 Count should remain at 1") assert.Equal(t, 30.0, gm.GpuDataMap["1"].Usage, "GPU 1 Usage should remain at 30") assert.Equal(t, 60.0, gm.GpuDataMap["1"].Power, "GPU 1 Power should remain at 60") // Verify snapshots were stored for this cache key assert.NotNil(t, gm.lastSnapshots[cacheKey]["0"]) assert.Equal(t, uint32(2), gm.lastSnapshots[cacheKey]["0"].count) assert.Equal(t, 100.0, gm.lastSnapshots[cacheKey]["0"].usage) assert.Equal(t, 200.0, gm.lastSnapshots[cacheKey]["0"].power) }) t.Run("handles zero count without panicking", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": { Name: "TestGPU", Count: 0, Usage: 0, Power: 0, }, }, } cacheKey := uint16(5000) var result map[string]system.GPUData assert.NotPanics(t, func() { result = gm.GetCurrentData(cacheKey) }) // Check that usage and power are 0 assert.Equal(t, 0.0, result["0"].Usage) assert.Equal(t, 0.0, result["0"].Power) // Verify count remains 0 assert.EqualValues(t, 0, gm.GpuDataMap["0"].Count) }) t.Run("uses last average when no new data arrives", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": { Name: "TestGPU", Temperature: 55.0, MemoryUsed: 1500, MemoryTotal: 8000, Usage: 100, // Will average to 50 Power: 200, // Will average to 100 Count: 2, }, }, } cacheKey := uint16(5000) // First collection - should calculate averages and store them result1 := gm.GetCurrentData(cacheKey) assert.InDelta(t, 50.0, result1["0"].Usage, 0.01) assert.InDelta(t, 100.0, result1["0"].Power, 0.01) assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should remain at 2") // Update temperature but no new usage/power data (count stays same) gm.GpuDataMap["0"].Temperature = 60.0 gm.GpuDataMap["0"].MemoryUsed = 1600 // Second collection - should use last averages since count hasn't changed (delta = 0) result2 := gm.GetCurrentData(cacheKey) assert.InDelta(t, 50.0, result2["0"].Usage, 0.01, "Should use last average") assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should use last average") assert.InDelta(t, 60.0, result2["0"].Temperature, 0.01, "Should use current temperature") assert.InDelta(t, 1600.0, result2["0"].MemoryUsed, 0.01, "Should use current memory") assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2") }) t.Run("tracks separate averages per cache key", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": { Name: "TestGPU", Temperature: 55.0, MemoryUsed: 1500, MemoryTotal: 8000, Usage: 100, // Initial: 100 over 2 counts = 50 avg Power: 200, // Initial: 200 over 2 counts = 100 avg Count: 2, }, }, } cacheKey1 := uint16(5000) cacheKey2 := uint16(10000) // First check with cacheKey1 - baseline result1 := gm.GetCurrentData(cacheKey1) assert.InDelta(t, 50.0, result1["0"].Usage, 0.01, "CacheKey1: Initial average should be 50") assert.InDelta(t, 100.0, result1["0"].Power, 0.01, "CacheKey1: Initial average should be 100") // Simulate GPU activity - accumulate more data gm.GpuDataMap["0"].Usage += 60 // Now total: 160 gm.GpuDataMap["0"].Power += 150 // Now total: 350 gm.GpuDataMap["0"].Count += 3 // Now total: 5 // Check with cacheKey1 again - should get delta since last cacheKey1 check result2 := gm.GetCurrentData(cacheKey1) assert.InDelta(t, 20.0, result2["0"].Usage, 0.01, "CacheKey1: Delta average should be 60/3 = 20") assert.InDelta(t, 50.0, result2["0"].Power, 0.01, "CacheKey1: Delta average should be 150/3 = 50") // Check with cacheKey2 for the first time - should get average since beginning result3 := gm.GetCurrentData(cacheKey2) assert.InDelta(t, 32.0, result3["0"].Usage, 0.01, "CacheKey2: Total average should be 160/5 = 32") assert.InDelta(t, 70.0, result3["0"].Power, 0.01, "CacheKey2: Total average should be 350/5 = 70") // Simulate more GPU activity gm.GpuDataMap["0"].Usage += 80 // Now total: 240 gm.GpuDataMap["0"].Power += 160 // Now total: 510 gm.GpuDataMap["0"].Count += 2 // Now total: 7 // Check with cacheKey1 - should get delta since last cacheKey1 check result4 := gm.GetCurrentData(cacheKey1) assert.InDelta(t, 40.0, result4["0"].Usage, 0.01, "CacheKey1: New delta average should be 80/2 = 40") assert.InDelta(t, 80.0, result4["0"].Power, 0.01, "CacheKey1: New delta average should be 160/2 = 80") // Check with cacheKey2 - should get delta since last cacheKey2 check result5 := gm.GetCurrentData(cacheKey2) assert.InDelta(t, 40.0, result5["0"].Usage, 0.01, "CacheKey2: Delta average should be 80/2 = 40") assert.InDelta(t, 80.0, result5["0"].Power, 0.01, "CacheKey2: Delta average should be 160/2 = 80") // Verify snapshots exist for both cache keys assert.NotNil(t, gm.lastSnapshots[cacheKey1]) assert.NotNil(t, gm.lastSnapshots[cacheKey2]) assert.NotNil(t, gm.lastSnapshots[cacheKey1]["0"]) assert.NotNil(t, gm.lastSnapshots[cacheKey2]["0"]) }) } func TestCalculateDeltaCount(t *testing.T) { gm := &GPUManager{} t.Run("with no previous snapshot", func(t *testing.T) { delta := gm.calculateDeltaCount(10, nil) assert.Equal(t, uint32(10), delta, "Should return current count when no snapshot exists") }) t.Run("with previous snapshot", func(t *testing.T) { snapshot := &gpuSnapshot{count: 5} delta := gm.calculateDeltaCount(15, snapshot) assert.Equal(t, uint32(10), delta, "Should return difference between current and snapshot") }) t.Run("with same count", func(t *testing.T) { snapshot := &gpuSnapshot{count: 10} delta := gm.calculateDeltaCount(10, snapshot) assert.Equal(t, uint32(0), delta, "Should return zero when count hasn't changed") }) } func TestCalculateDeltas(t *testing.T) { gm := &GPUManager{} t.Run("with no previous snapshot", func(t *testing.T) { gpu := &system.GPUData{ Usage: 100.5, Power: 250.75, PowerPkg: 300.25, } deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, nil) assert.Equal(t, 100.5, deltaUsage) assert.Equal(t, 250.75, deltaPower) assert.Equal(t, 300.25, deltaPowerPkg) }) t.Run("with previous snapshot", func(t *testing.T) { gpu := &system.GPUData{ Usage: 150.5, Power: 300.75, PowerPkg: 400.25, } snapshot := &gpuSnapshot{ usage: 100.5, power: 250.75, powerPkg: 300.25, } deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, snapshot) assert.InDelta(t, 50.0, deltaUsage, 0.01) assert.InDelta(t, 50.0, deltaPower, 0.01) assert.InDelta(t, 100.0, deltaPowerPkg, 0.01) }) } func TestCalculateIntelGPUUsage(t *testing.T) { gm := &GPUManager{} t.Run("with no previous snapshot", func(t *testing.T) { gpuAvg := &system.GPUData{ Engines: make(map[string]float64), } gpu := &system.GPUData{ Engines: map[string]float64{ "Render/3D": 80.0, "Video": 40.0, "Compute": 60.0, }, } maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, nil, 2) assert.Equal(t, 40.0, maxUsage, "Should return max engine usage (80/2=40)") assert.Equal(t, 40.0, gpuAvg.Engines["Render/3D"]) assert.Equal(t, 20.0, gpuAvg.Engines["Video"]) assert.Equal(t, 30.0, gpuAvg.Engines["Compute"]) }) t.Run("with previous snapshot", func(t *testing.T) { gpuAvg := &system.GPUData{ Engines: make(map[string]float64), } gpu := &system.GPUData{ Engines: map[string]float64{ "Render/3D": 180.0, "Video": 100.0, "Compute": 140.0, }, } snapshot := &gpuSnapshot{ engines: map[string]float64{ "Render/3D": 80.0, "Video": 40.0, "Compute": 60.0, }, } maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 5) // Deltas: Render/3D=100, Video=60, Compute=80 over 5 counts assert.Equal(t, 20.0, maxUsage, "Should return max engine delta (100/5=20)") assert.Equal(t, 20.0, gpuAvg.Engines["Render/3D"]) assert.Equal(t, 12.0, gpuAvg.Engines["Video"]) assert.Equal(t, 16.0, gpuAvg.Engines["Compute"]) }) t.Run("handles missing engine in snapshot", func(t *testing.T) { gpuAvg := &system.GPUData{ Engines: make(map[string]float64), } gpu := &system.GPUData{ Engines: map[string]float64{ "Render/3D": 100.0, "NewEngine": 50.0, }, } snapshot := &gpuSnapshot{ engines: map[string]float64{ "Render/3D": 80.0, // NewEngine doesn't exist in snapshot }, } maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 2) assert.Equal(t, 25.0, maxUsage) assert.Equal(t, 10.0, gpuAvg.Engines["Render/3D"], "Should use delta for existing engine") assert.Equal(t, 25.0, gpuAvg.Engines["NewEngine"], "Should use full value for new engine") }) } func TestUpdateInstantaneousValues(t *testing.T) { gm := &GPUManager{} t.Run("updates temperature, memory used and total", func(t *testing.T) { gpuAvg := &system.GPUData{ Temperature: 50.123, MemoryUsed: 1000.456, MemoryTotal: 8000.789, } gpu := &system.GPUData{ Temperature: 75.567, MemoryUsed: 2500.891, MemoryTotal: 8192.234, } gm.updateInstantaneousValues(gpuAvg, gpu) assert.Equal(t, 75.57, gpuAvg.Temperature, "Should update and round temperature") assert.Equal(t, 2500.89, gpuAvg.MemoryUsed, "Should update and round memory used") assert.Equal(t, 8192.23, gpuAvg.MemoryTotal, "Should update and round memory total") }) } func TestStoreSnapshot(t *testing.T) { gm := &GPUManager{ lastSnapshots: make(map[uint16]map[string]*gpuSnapshot), } t.Run("stores standard GPU snapshot", func(t *testing.T) { cacheKey := uint16(5000) gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot) gpu := &system.GPUData{ Count: 10.0, Usage: 150.5, Power: 250.75, PowerPkg: 300.25, } gm.storeSnapshot("0", gpu, cacheKey) snapshot := gm.lastSnapshots[cacheKey]["0"] assert.NotNil(t, snapshot) assert.Equal(t, uint32(10), snapshot.count) assert.Equal(t, 150.5, snapshot.usage) assert.Equal(t, 250.75, snapshot.power) assert.Equal(t, 300.25, snapshot.powerPkg) assert.Nil(t, snapshot.engines, "Should not have engines for standard GPU") }) t.Run("stores Intel GPU snapshot with engines", func(t *testing.T) { cacheKey := uint16(10000) gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot) gpu := &system.GPUData{ Count: 5.0, Usage: 100.0, Power: 200.0, PowerPkg: 250.0, Engines: map[string]float64{ "Render/3D": 80.0, "Video": 40.0, }, } gm.storeSnapshot("0", gpu, cacheKey) snapshot := gm.lastSnapshots[cacheKey]["0"] assert.NotNil(t, snapshot) assert.Equal(t, uint32(5), snapshot.count) assert.NotNil(t, snapshot.engines, "Should have engines for Intel GPU") assert.Equal(t, 80.0, snapshot.engines["Render/3D"]) assert.Equal(t, 40.0, snapshot.engines["Video"]) assert.Len(t, snapshot.engines, 2) }) t.Run("overwrites existing snapshot", func(t *testing.T) { cacheKey := uint16(5000) gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot) // Store initial snapshot gpu1 := &system.GPUData{Count: 5.0, Usage: 100.0, Power: 200.0} gm.storeSnapshot("0", gpu1, cacheKey) // Store updated snapshot gpu2 := &system.GPUData{Count: 10.0, Usage: 250.0, Power: 400.0} gm.storeSnapshot("0", gpu2, cacheKey) snapshot := gm.lastSnapshots[cacheKey]["0"] assert.Equal(t, uint32(10), snapshot.count, "Should overwrite previous count") assert.Equal(t, 250.0, snapshot.usage, "Should overwrite previous usage") assert.Equal(t, 400.0, snapshot.power, "Should overwrite previous power") }) } func TestCountGPUNames(t *testing.T) { t.Run("returns empty map for no GPUs", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } counts := gm.countGPUNames() assert.Empty(t, counts) }) t.Run("counts unique GPU names", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": {Name: "GPU A"}, "1": {Name: "GPU B"}, "2": {Name: "GPU C"}, }, } counts := gm.countGPUNames() assert.Equal(t, 1, counts["GPU A"]) assert.Equal(t, 1, counts["GPU B"]) assert.Equal(t, 1, counts["GPU C"]) assert.Len(t, counts, 3) }) t.Run("counts duplicate GPU names", func(t *testing.T) { gm := &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": {Name: "RTX 4090"}, "1": {Name: "RTX 4090"}, "2": {Name: "RTX 4090"}, "3": {Name: "RTX 3080"}, }, } counts := gm.countGPUNames() assert.Equal(t, 3, counts["RTX 4090"]) assert.Equal(t, 1, counts["RTX 3080"]) assert.Len(t, counts, 2) }) } func TestInitializeSnapshots(t *testing.T) { t.Run("initializes all maps from scratch", func(t *testing.T) { gm := &GPUManager{} cacheKey := uint16(5000) gm.initializeSnapshots(cacheKey) assert.NotNil(t, gm.lastAvgData) assert.NotNil(t, gm.lastSnapshots) assert.NotNil(t, gm.lastSnapshots[cacheKey]) }) t.Run("initializes only missing maps", func(t *testing.T) { gm := &GPUManager{ lastAvgData: make(map[string]system.GPUData), } cacheKey := uint16(5000) gm.initializeSnapshots(cacheKey) assert.NotNil(t, gm.lastAvgData, "Should preserve existing lastAvgData") assert.NotNil(t, gm.lastSnapshots) assert.NotNil(t, gm.lastSnapshots[cacheKey]) }) t.Run("adds new cache key to existing snapshots", func(t *testing.T) { existingKey := uint16(5000) newKey := uint16(10000) gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ existingKey: {"0": {count: 10}}, }, } gm.initializeSnapshots(newKey) assert.NotNil(t, gm.lastSnapshots[existingKey], "Should preserve existing cache key") assert.NotNil(t, gm.lastSnapshots[newKey], "Should add new cache key") assert.NotNil(t, gm.lastSnapshots[existingKey]["0"], "Should preserve existing snapshot data") }) } func TestCalculateGPUAverage(t *testing.T) { t.Run("returns cached average when deltaCount is zero", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: { "0": {count: 10, usage: 100, power: 200}, }, }, lastAvgData: map[string]system.GPUData{ "0": {Usage: 50.0, Power: 100.0}, }, } gpu := &system.GPUData{ Count: 10.0, // Same as snapshot, so delta = 0 Usage: 100.0, Power: 200.0, Temperature: 50.0, // Non-zero to avoid "suspended" check } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, 50.0, result.Usage, "Should return cached average") assert.Equal(t, 100.0, result.Power, "Should return cached average") }) t.Run("returns zero value when GPU is suspended", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: { "0": {count: 10, usage: 100, power: 200}, }, }, lastAvgData: map[string]system.GPUData{ "0": {Usage: 50.0, Power: 100.0}, }, } gpu := &system.GPUData{ Name: "Test GPU", Count: 10.0, Temperature: 0, MemoryUsed: 0, } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, 0.0, result.Usage, "Should return zero usage") assert.Equal(t, 0.0, result.Power, "Should return zero power") }) t.Run("calculates average for standard GPU", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: {}, }, lastAvgData: make(map[string]system.GPUData), } gpu := &system.GPUData{ Name: "Test GPU", Count: 4.0, Usage: 200.0, // 200 / 4 = 50 Power: 400.0, // 400 / 4 = 100 } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, 50.0, result.Usage) assert.Equal(t, 100.0, result.Power) assert.Equal(t, "Test GPU", result.Name) }) t.Run("calculates average for Intel GPU with engines", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: {}, }, lastAvgData: make(map[string]system.GPUData), } gpu := &system.GPUData{ Name: "Intel GPU", Count: 5.0, Power: 500.0, PowerPkg: 600.0, Engines: map[string]float64{ "Render/3D": 100.0, // 100 / 5 = 20 "Video": 50.0, // 50 / 5 = 10 }, } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, 100.0, result.Power) assert.Equal(t, 120.0, result.PowerPkg) assert.Equal(t, 20.0, result.Usage, "Should use max engine usage") assert.Equal(t, 20.0, result.Engines["Render/3D"]) assert.Equal(t, 10.0, result.Engines["Video"]) }) t.Run("calculates delta from previous snapshot", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: { "0": { count: 2, usage: 50.0, power: 100.0, powerPkg: 120.0, }, }, }, lastAvgData: make(map[string]system.GPUData), } gpu := &system.GPUData{ Name: "Test GPU", Count: 7.0, // Delta = 7 - 2 = 5 Usage: 200.0, // Delta = 200 - 50 = 150, avg = 150/5 = 30 Power: 350.0, // Delta = 350 - 100 = 250, avg = 250/5 = 50 PowerPkg: 420.0, // Delta = 420 - 120 = 300, avg = 300/5 = 60 } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, 30.0, result.Usage) assert.Equal(t, 50.0, result.Power) }) t.Run("stores result in lastAvgData", func(t *testing.T) { gm := &GPUManager{ lastSnapshots: map[uint16]map[string]*gpuSnapshot{ 5000: {}, }, lastAvgData: make(map[string]system.GPUData), } gpu := &system.GPUData{ Count: 2.0, Usage: 100.0, Power: 200.0, } result := gm.calculateGPUAverage("0", gpu, 5000) assert.Equal(t, result, gm.lastAvgData["0"], "Should store calculated average") }) } func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) { // Save original PATH origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) hasAmdSysfs := (&GPUManager{}).hasAmdSysfs() tests := []struct { name string setupCommands func(string) error wantNvidiaSmi bool wantRocmSmi bool wantTegrastats bool wantNvtop bool wantErr bool }{ { name: "nvidia-smi not available", setupCommands: func(_ string) error { return nil }, wantNvidiaSmi: false, wantRocmSmi: false, wantTegrastats: false, wantNvtop: false, wantErr: true, }, { name: "nvidia-smi available", setupCommands: func(tempDir string) error { path := filepath.Join(tempDir, "nvidia-smi") script := `#!/bin/sh echo "test"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, wantNvidiaSmi: true, wantTegrastats: false, wantRocmSmi: false, wantNvtop: false, wantErr: false, }, { name: "rocm-smi available", setupCommands: func(tempDir string) error { path := filepath.Join(tempDir, "rocm-smi") script := `#!/bin/sh echo "test"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, wantNvidiaSmi: false, wantRocmSmi: true, wantTegrastats: false, wantNvtop: false, wantErr: false, }, { name: "tegrastats available", setupCommands: func(tempDir string) error { path := filepath.Join(tempDir, "tegrastats") script := `#!/bin/sh echo "test"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, wantNvidiaSmi: false, wantRocmSmi: false, wantTegrastats: true, wantNvtop: false, wantErr: false, }, { name: "nvtop available", setupCommands: func(tempDir string) error { path := filepath.Join(tempDir, "nvtop") script := `#!/bin/sh echo "[]"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, wantNvidiaSmi: false, wantRocmSmi: false, wantTegrastats: false, wantNvtop: true, wantErr: false, }, { name: "no gpu tools available", setupCommands: func(_ string) error { os.Setenv("PATH", "") return nil }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() os.Setenv("PATH", tempDir) if err := tt.setupCommands(tempDir); err != nil { t.Fatal(err) } gm := &GPUManager{} caps := gm.discoverGpuCapabilities() var err error if !hasAnyGpuCollector(caps) { err = fmt.Errorf(noGPUFoundMsg) } priorities := gm.resolveLegacyCollectorPriority(caps) hasPriority := func(source collectorSource) bool { for _, s := range priorities { if s == source { return true } } return false } gotNvidiaSmi := hasPriority(collectorSourceNvidiaSMI) gotRocmSmi := hasPriority(collectorSourceRocmSMI) gotTegrastats := caps.hasTegrastats gotNvtop := caps.hasNvtop t.Logf("nvidiaSmi: %v, rocmSmi: %v, tegrastats: %v", gotNvidiaSmi, gotRocmSmi, gotTegrastats) wantErr := tt.wantErr if hasAmdSysfs && (tt.name == "nvidia-smi not available" || tt.name == "no gpu tools available") { wantErr = false } if wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.wantNvidiaSmi, gotNvidiaSmi) assert.Equal(t, tt.wantRocmSmi, gotRocmSmi) assert.Equal(t, tt.wantTegrastats, gotTegrastats) assert.Equal(t, tt.wantNvtop, gotNvtop) }) } } func TestCollectorStartHelpers(t *testing.T) { // Save original PATH origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) // Set up temp dir with the commands dir := t.TempDir() os.Setenv("PATH", dir) tests := []struct { name string command string setup func(t *testing.T) error validate func(t *testing.T, gm *GPUManager) gm *GPUManager }{ { name: "nvidia-smi collector", command: "nvidia-smi", setup: func(t *testing.T) error { path := filepath.Join(dir, "nvidia-smi") script := `#!/bin/sh echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, validate: func(t *testing.T, gm *GPUManager) { gpu, exists := gm.GpuDataMap["0"] assert.True(t, exists) if exists { assert.Equal(t, "Test GPU", gpu.Name) assert.Equal(t, 50.0, gpu.Temperature) } }, }, { name: "rocm-smi collector", command: "rocm-smi", setup: func(t *testing.T) error { path := filepath.Join(dir, "rocm-smi") script := `#!/bin/sh echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, validate: func(t *testing.T, gm *GPUManager) { gpu, exists := gm.GpuDataMap["34756"] assert.True(t, exists) if exists { assert.Equal(t, "Rembrandt [Radeon 680M]", gpu.Name) assert.InDelta(t, 49.0, gpu.Temperature, 0.01) assert.InDelta(t, 28.159, gpu.Power, 0.01) } }, }, { name: "tegrastats collector", command: "tegrastats", setup: func(t *testing.T) error { path := filepath.Join(dir, "tegrastats") script := `#!/bin/sh echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, validate: func(t *testing.T, gm *GPUManager) { gpu, exists := gm.GpuDataMap["0"] assert.True(t, exists) if exists { assert.InDelta(t, 70.0, gpu.Temperature, 0.1) } }, gm: &GPUManager{ GpuDataMap: map[string]*system.GPUData{ "0": {}, }, }, }, { name: "nvtop collector", command: "nvtop", setup: func(t *testing.T) error { path := filepath.Join(dir, "nvtop") script := `#!/bin/sh echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'` if err := os.WriteFile(path, []byte(script), 0755); err != nil { return err } return nil }, validate: func(t *testing.T, gm *GPUManager) { gpu, exists := gm.GpuDataMap["n0"] assert.True(t, exists) if exists { assert.Equal(t, "NVIDIA Test GPU", gpu.Name) assert.Equal(t, 52.0, gpu.Temperature) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := tt.setup(t); err != nil { t.Fatal(err) } if tt.gm == nil { tt.gm = &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } } switch tt.command { case nvidiaSmiCmd: tt.gm.startNvidiaSmiCollector("4") case rocmSmiCmd: tt.gm.startRocmSmiCollector(4300 * time.Millisecond) case tegraStatsCmd: tt.gm.startTegraStatsCollector("3700") case nvtopCmd: tt.gm.startNvtopCollector("30", nil) default: t.Fatalf("unknown test command %q", tt.command) } time.Sleep(50 * time.Millisecond) // Give collector time to run tt.validate(t, tt.gm) }) } } func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) { origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi") nvtopPath := filepath.Join(dir, "nvtop") nvtopScript := `#!/bin/sh echo 'not-json'` require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755)) nvidiaPath := filepath.Join(dir, "nvidia-smi") nvidiaScript := `#!/bin/sh echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"` require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755)) gm, err := NewGPUManager() require.NoError(t, err) require.NotNil(t, gm) time.Sleep(150 * time.Millisecond) gpu, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "Priority GPU", gpu.Name) assert.Equal(t, 45.0, gpu.Temperature) } func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) { origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi") intelPath := filepath.Join(dir, "intel_gpu_top") intelScript := `#!/bin/sh echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS" echo " req act /s % gpu pkg rd wr % se wa % se wa" echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0" echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0" ` require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755)) rocmPath := filepath.Join(dir, "rocm-smi") rocmScript := `#!/bin/sh echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}' ` require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755)) gm, err := NewGPUManager() require.NoError(t, err) require.NotNil(t, gm) time.Sleep(150 * time.Millisecond) _, intelOk := gm.GpuDataMap["i0"] _, amdOk := gm.GpuDataMap["34756"] assert.True(t, intelOk) assert.True(t, amdOk) } func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) { origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi") nvidiaPath := filepath.Join(dir, "nvidia-smi") nvidiaScript := `#!/bin/sh echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"` require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755)) gm, err := NewGPUManager() require.NoError(t, err) require.NotNil(t, gm) time.Sleep(150 * time.Millisecond) gpu, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "Fallback GPU", gpu.Name) } func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) { origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) t.Run("configured valid collector unavailable", func(t *testing.T) { t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi") gm, err := NewGPUManager() require.Nil(t, gm) require.Error(t, err) assert.Contains(t, err.Error(), "no configured GPU collectors are available") }) t.Run("configured collector list has only unknown entries", func(t *testing.T) { t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "bad,unknown") gm, err := NewGPUManager() require.Nil(t, gm) require.Error(t, err) assert.Contains(t, err.Error(), "no configured GPU collectors are available") }) } func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) { origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi") tegraPath := filepath.Join(dir, "tegrastats") tegraScript := `#!/bin/sh echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"` require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755)) gm, err := NewGPUManager() require.NoError(t, err) require.NotNil(t, gm) time.Sleep(100 * time.Millisecond) gpu, ok := gm.GpuDataMap["0"] require.True(t, ok) assert.Equal(t, "GPU", gpu.Name) } // TestAccumulationTableDriven tests the accumulation behavior for all three GPU types func TestAccumulation(t *testing.T) { type expectedGPUValues struct { temperature float64 memoryUsed float64 memoryTotal float64 usage float64 power float64 count float64 avgUsage float64 avgPower float64 } tests := []struct { name string initialGPUData map[string]*system.GPUData dataSamples [][]byte parser func(*GPUManager) func([]byte) bool expectedValues map[string]expectedGPUValues }{ { name: "Jetson GPU accumulation", initialGPUData: map[string]*system.GPUData{ "0": { Name: "Jetson", Temperature: 0, Usage: 0, Power: 0, Count: 0, }, }, dataSamples: [][]byte{ []byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 30% tj@50.5C VDD_GPU_SOC 1000mW"), []byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 40% tj@60.5C VDD_GPU_SOC 1200mW"), []byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 50% tj@70.5C VDD_GPU_SOC 1400mW"), }, parser: func(gm *GPUManager) func([]byte) bool { return gm.getJetsonParser() }, expectedValues: map[string]expectedGPUValues{ "0": { temperature: 70.5, // Last value memoryUsed: 1024, // Last value memoryTotal: 4096, // Last value usage: 120.0, // Accumulated: 30 + 40 + 50 power: 3.6, // Accumulated: 1.0 + 1.2 + 1.4 count: 3, avgUsage: 40.0, // 120 / 3 avgPower: 1.2, // 3.6 / 3 }, }, }, { name: "NVIDIA GPU accumulation", initialGPUData: map[string]*system.GPUData{ // NVIDIA parser will create the GPU data entries }, dataSamples: [][]byte{ []byte("0, NVIDIA GeForce RTX 3080, 50, 5000, 10000, 30, 200"), []byte("0, NVIDIA GeForce RTX 3080, 60, 6000, 10000, 40, 250"), []byte("0, NVIDIA GeForce RTX 3080, 70, 7000, 10000, 50, 300"), }, parser: func(gm *GPUManager) func([]byte) bool { return gm.parseNvidiaData }, expectedValues: map[string]expectedGPUValues{ "0": { temperature: 70.0, // Last value memoryUsed: 7000.0 / 1.024, // Last value memoryTotal: 10000.0 / 1.024, // Last value usage: 120.0, // Accumulated: 30 + 40 + 50 power: 750.0, // Accumulated: 200 + 250 + 300 count: 3, avgUsage: 40.0, // 120 / 3 avgPower: 250.0, // 750 / 3 }, }, }, { name: "AMD GPU accumulation", initialGPUData: map[string]*system.GPUData{ // AMD parser will create the GPU data entries }, dataSamples: [][]byte{ []byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "50.0", "Current Socket Graphics Package Power (W)": "100.0", "GPU use (%)": "30", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "1073741824", "Card Series": "Radeon RX 6800"}}`), []byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "60.0", "Current Socket Graphics Package Power (W)": "150.0", "GPU use (%)": "40", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "2147483648", "Card Series": "Radeon RX 6800"}}`), []byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "70.0", "Current Socket Graphics Package Power (W)": "200.0", "GPU use (%)": "50", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "3221225472", "Card Series": "Radeon RX 6800"}}`), }, parser: func(gm *GPUManager) func([]byte) bool { return gm.parseAmdData }, expectedValues: map[string]expectedGPUValues{ "34756": { temperature: 70.0, // Last value memoryUsed: 3221225472.0 / (1024 * 1024), // Last value memoryTotal: 10737418240.0 / (1024 * 1024), // Last value usage: 120.0, // Accumulated: 30 + 40 + 50 power: 450.0, // Accumulated: 100 + 150 + 200 count: 3, avgUsage: 40.0, // 120 / 3 avgPower: 150.0, // 450 / 3 }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a new GPUManager for each test gm := &GPUManager{ GpuDataMap: tt.initialGPUData, } // Get the parser function parser := tt.parser(gm) // Process each data sample for i, sample := range tt.dataSamples { valid := parser(sample) assert.True(t, valid, "Sample %d should be valid", i) } // Check accumulated values for id, expected := range tt.expectedValues { gpu, exists := gm.GpuDataMap[id] assert.True(t, exists, "GPU with ID %s should exist", id) if !exists { continue } assert.EqualValues(t, expected.temperature, gpu.Temperature, "Temperature should match") assert.EqualValues(t, expected.memoryUsed, gpu.MemoryUsed, "Memory used should match") assert.EqualValues(t, expected.memoryTotal, gpu.MemoryTotal, "Memory total should match") assert.EqualValues(t, expected.usage, gpu.Usage, "Usage should match") assert.EqualValues(t, expected.power, gpu.Power, "Power should match") assert.Equal(t, expected.count, gpu.Count, "Count should match") } // Verify average calculation in GetCurrentData cacheKey := uint16(5000) result := gm.GetCurrentData(cacheKey) for id, expected := range tt.expectedValues { gpu, exists := result[id] assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id) if !exists { continue } assert.EqualValues(t, expected.temperature, gpu.Temperature, "Temperature in GetCurrentData should match") assert.EqualValues(t, expected.avgUsage, gpu.Usage, "Average usage in GetCurrentData should match") assert.EqualValues(t, expected.avgPower, gpu.Power, "Average power in GetCurrentData should match") } // Verify that accumulators in the original map are NOT reset (they keep growing) for id, expected := range tt.expectedValues { gpu, exists := gm.GpuDataMap[id] assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id) if !exists { continue } assert.EqualValues(t, expected.count, gpu.Count, "Count should remain at accumulated value for GPU ID %s", id) assert.EqualValues(t, expected.usage, gpu.Usage, "Usage should remain at accumulated value for GPU ID %s", id) assert.EqualValues(t, expected.power, gpu.Power, "Power should remain at accumulated value for GPU ID %s", id) } }) } } func TestIntelUpdateFromStats(t *testing.T) { gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } // First sample with power and two engines sample1 := intelGpuStats{ PowerGPU: 10.5, Engines: map[string]float64{ "Render/3D": 20.0, "Video": 5.0, }, } ok := gm.updateIntelFromStats(&sample1) assert.True(t, ok) gpu := gm.GpuDataMap["i0"] require.NotNil(t, gpu) assert.Equal(t, "GPU", gpu.Name) assert.EqualValues(t, 10.5, gpu.Power) assert.EqualValues(t, 20.0, gpu.Engines["Render/3D"]) assert.EqualValues(t, 5.0, gpu.Engines["Video"]) assert.Equal(t, float64(1), gpu.Count) // Second sample with zero power (should not add) and additional engine busy sample2 := intelGpuStats{ PowerGPU: 0.0, Engines: map[string]float64{ "Render/3D": 10.0, "Video": 2.5, "Blitter": 1.0, }, } // zero power should not increment power accumulator ok = gm.updateIntelFromStats(&sample2) assert.True(t, ok) gpu = gm.GpuDataMap["i0"] require.NotNil(t, gpu) assert.EqualValues(t, 10.5, gpu.Power) assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10 assert.EqualValues(t, 7.5, gpu.Engines["Video"]) // 5 + 2.5 assert.EqualValues(t, 1.0, gpu.Engines["Blitter"]) assert.Equal(t, float64(2), gpu.Count) } func TestIntelCollectorStreaming(t *testing.T) { // Save and override PATH origPath := os.Getenv("PATH") defer os.Setenv("PATH", origPath) dir := t.TempDir() os.Setenv("PATH", dir) // Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits scriptPath := filepath.Join(dir, "intel_gpu_top") script := `#!/bin/sh echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS" echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa" echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0" echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0" echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1" echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"` if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatal(err) } gm := &GPUManager{ GpuDataMap: make(map[string]*system.GPUData), } // Run the collector once; it should read four samples but skip the first and return if err := gm.collectIntelStats(); err != nil { t.Fatalf("collectIntelStats error: %v", err) } gpu := gm.GpuDataMap["i0"] require.NotNil(t, gpu) // Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0 assert.EqualValues(t, 6.0, gpu.Power) assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences // Engines aggregated from samples 2-4 assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75 assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00 assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50 // Count should be 3 samples (first is skipped) assert.Equal(t, float64(3), gpu.Count) } func TestParseIntelHeaders(t *testing.T) { tests := []struct { name string header1 string header2 string wantEngineNames []string wantFriendlyNames []string wantPowerIndex int wantPreEngineCols int }{ { name: "basic headers with RCS BCS VCS", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS", header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa", wantEngineNames: []string{"RCS", "BCS", "VCS"}, wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"}, wantPowerIndex: 4, // "gpu" is at index 4 wantPreEngineCols: 8, // 17 total cols - 3*3 = 8 }, { name: "basic headers with RCS BCS VCS using index in name", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2", header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa", wantEngineNames: []string{"RCS", "BCS", "VCS"}, wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"}, wantPowerIndex: 4, // "gpu" is at index 4 wantPreEngineCols: 8, // 17 total cols - 3*3 = 8 }, { name: "headers with only RCS", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS", header2: " req act /s % gpu pkg rd wr % se wa", wantEngineNames: []string{"RCS"}, wantFriendlyNames: []string{"Render/3D"}, wantPowerIndex: 4, wantPreEngineCols: 8, // 11 total - 3*1 = 8 }, { name: "headers with VECS and CCS", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS", header2: " req act /s % gpu pkg rd wr % se wa % se wa", wantEngineNames: []string{"VECS", "CCS"}, wantFriendlyNames: []string{"VideoEnhance", "Compute"}, wantPowerIndex: 4, wantPreEngineCols: 8, // 14 total - 3*2 = 8 }, { name: "no engines", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s", header2: " req act /s % gpu pkg rd wr", wantEngineNames: nil, // no engines found, slices remain nil wantFriendlyNames: nil, wantPowerIndex: -1, // no engines, so no search wantPreEngineCols: 0, }, { name: "power index not found", header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS", header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu" wantEngineNames: []string{"RCS"}, wantFriendlyNames: []string{"Render/3D"}, wantPowerIndex: -1, // "gpu" not found wantPreEngineCols: 8, // 11 total - 3*1 = 8 }, { name: "empty headers", header1: "", header2: "", wantEngineNames: nil, // empty input, slices remain nil wantFriendlyNames: nil, wantPowerIndex: -1, wantPreEngineCols: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gm := &GPUManager{} engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2) assert.Equal(t, tt.wantEngineNames, engineNames) assert.Equal(t, tt.wantFriendlyNames, friendlyNames) assert.Equal(t, tt.wantPowerIndex, powerIndex) assert.Equal(t, tt.wantPreEngineCols, preEngineCols) }) } } func TestParseIntelData(t *testing.T) { tests := []struct { name string line string engineNames []string friendlyNames []string powerIndex int preEngineCols int wantPowerGPU float64 wantEngines map[string]float64 wantErr error }{ { name: "basic data with power and engines", line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 1.50, wantEngines: map[string]float64{ "Render/3D": 12.34, "Blitter": 0.00, "Video": 5.00, }, }, { name: "data with zero power", line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0", engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 0.00, wantEngines: map[string]float64{ "Render/3D": 0.00, "Blitter": 0.00, "Video": 0.00, }, }, { name: "data with no power index", line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: -1, preEngineCols: 8, wantPowerGPU: 0.0, // no power parsed wantEngines: map[string]float64{ "Render/3D": 12.34, "Blitter": 0.00, "Video": 5.00, }, }, { name: "data with insufficient columns", line: "373 373 224 45 1.50", // too few columns engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 0.0, wantEngines: nil, // empty sample returned wantErr: errNoValidData, }, { name: "empty line", line: "", engineNames: []string{"RCS"}, friendlyNames: []string{"Render/3D"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 0.0, wantEngines: nil, wantErr: errNoValidData, }, { name: "data with invalid power value", line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0", engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 0.0, // N/A can't be parsed wantEngines: map[string]float64{ "Render/3D": 12.34, "Blitter": 0.00, "Video": 5.00, }, }, { name: "data with invalid engine value", line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0", engineNames: []string{"RCS", "BCS", "VCS"}, friendlyNames: []string{"Render/3D", "Blitter", "Video"}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 1.50, wantEngines: map[string]float64{ "Render/3D": 0.0, // N/A becomes 0 "Blitter": 0.00, "Video": 5.00, }, }, { name: "data with no engines", line: "373 373 224 45 1.50 4.13 2554 714", engineNames: []string{}, friendlyNames: []string{}, powerIndex: 4, preEngineCols: 8, wantPowerGPU: 1.50, wantEngines: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gm := &GPUManager{} sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols) assert.Equal(t, tt.wantErr, err) assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU) assert.Equal(t, tt.wantEngines, sample.Engines) }) } } func TestIntelCollectorDeviceEnv(t *testing.T) { dir := t.TempDir() t.Setenv("PATH", dir) // Prepare a file to capture args argsFile := filepath.Join(dir, "args.txt") // Create a fake intel_gpu_top that records its arguments and prints minimal valid output scriptPath := filepath.Join(dir, "intel_gpu_top") script := fmt.Sprintf(`#!/bin/sh echo "$@" > %s echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS" echo " req act /s %% gpu pkg rd wr %% se wa %% se wa" echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0" echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0" `, argsFile) if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { t.Fatal(err) } // Set device selector via prefixed env var t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov") gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)} if err := gm.collectIntelStats(); err != nil { t.Fatalf("collectIntelStats error: %v", err) } // Verify that -d sriov was passed data, err := os.ReadFile(argsFile) if err != nil { t.Fatalf("failed reading args file: %v", err) } argsStr := strings.TrimSpace(string(data)) require.Contains(t, argsStr, "-d sriov") require.Contains(t, argsStr, "-s ") require.Contains(t, argsStr, "-l") } ================================================ FILE: agent/handlers.go ================================================ package agent import ( "context" "errors" "fmt" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/smart" "log/slog" ) // HandlerContext provides context for request handlers type HandlerContext struct { Client *WebSocketClient Agent *Agent Request *common.HubRequest[cbor.RawMessage] RequestID *uint32 HubVerified bool // SendResponse abstracts how a handler sends responses (WS or SSH) SendResponse func(data any, requestID *uint32) error } // RequestHandler defines the interface for handling specific websocket request types type RequestHandler interface { // Handle processes the request and returns an error if unsuccessful Handle(hctx *HandlerContext) error } // Responder sends handler responses back to the hub (over WS or SSH) type Responder interface { SendResponse(data any, requestID *uint32) error } // HandlerRegistry manages the mapping between actions and their handlers type HandlerRegistry struct { handlers map[common.WebSocketAction]RequestHandler } // NewHandlerRegistry creates a new handler registry with default handlers func NewHandlerRegistry() *HandlerRegistry { registry := &HandlerRegistry{ handlers: make(map[common.WebSocketAction]RequestHandler), } registry.Register(common.GetData, &GetDataHandler{}) registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{}) registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{}) registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{}) registry.Register(common.GetSmartData, &GetSmartDataHandler{}) registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{}) return registry } // Register registers a handler for a specific action type func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) { hr.handlers[action] = handler } // Handle routes the request to the appropriate handler func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error { handler, exists := hr.handlers[hctx.Request.Action] if !exists { return fmt.Errorf("unknown action: %d", hctx.Request.Action) } // Check verification requirement - default to requiring verification if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified { return errors.New("hub not verified") } // Log handler execution for debugging // slog.Debug("Executing handler", "action", hctx.Request.Action) return handler.Handle(hctx) } // GetHandler returns the handler for a specific action func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) { handler, exists := hr.handlers[action] return handler, exists } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // GetDataHandler handles system data requests type GetDataHandler struct{} func (h *GetDataHandler) Handle(hctx *HandlerContext) error { var options common.DataRequestOptions _ = cbor.Unmarshal(hctx.Request.Data, &options) sysStats := hctx.Agent.gatherStats(options) return hctx.SendResponse(sysStats, hctx.RequestID) } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // CheckFingerprintHandler handles authentication challenges type CheckFingerprintHandler struct{} func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error { return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID) } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // GetContainerLogsHandler handles container log requests type GetContainerLogsHandler struct{} func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error { if hctx.Agent.dockerManager == nil { return hctx.SendResponse("", hctx.RequestID) } var req common.ContainerLogsRequest if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { return err } ctx := context.Background() logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID) if err != nil { return err } return hctx.SendResponse(logContent, hctx.RequestID) } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // GetContainerInfoHandler handles container info requests type GetContainerInfoHandler struct{} func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error { if hctx.Agent.dockerManager == nil { return hctx.SendResponse("", hctx.RequestID) } var req common.ContainerInfoRequest if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { return err } ctx := context.Background() info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID) if err != nil { return err } return hctx.SendResponse(string(info), hctx.RequestID) } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // GetSmartDataHandler handles SMART data requests type GetSmartDataHandler struct{} func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error { if hctx.Agent.smartManager == nil { // return empty map to indicate no data return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID) } if err := hctx.Agent.smartManager.Refresh(false); err != nil { slog.Debug("smart refresh failed", "err", err) } data := hctx.Agent.smartManager.GetCurrentData() return hctx.SendResponse(data, hctx.RequestID) } //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// // GetSystemdInfoHandler handles detailed systemd service info requests type GetSystemdInfoHandler struct{} func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error { if hctx.Agent.systemdManager == nil { return errors.ErrUnsupported } var req common.SystemdInfoRequest if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil { return err } if req.ServiceName == "" { return errors.New("service name is required") } details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName) if err != nil { return err } return hctx.SendResponse(details, hctx.RequestID) } ================================================ FILE: agent/handlers_test.go ================================================ //go:build testing package agent import ( "testing" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/stretchr/testify/assert" ) // MockHandler for testing type MockHandler struct { requiresVerification bool description string handleFunc func(ctx *HandlerContext) error } func (m *MockHandler) Handle(ctx *HandlerContext) error { if m.handleFunc != nil { return m.handleFunc(ctx) } return nil } func (m *MockHandler) RequiresVerification() bool { return m.requiresVerification } // TestHandlerRegistry tests the handler registry functionality func TestHandlerRegistry(t *testing.T) { t.Run("default registration", func(t *testing.T) { registry := NewHandlerRegistry() // Check default handlers are registered getDataHandler, exists := registry.GetHandler(common.GetData) assert.True(t, exists) assert.IsType(t, &GetDataHandler{}, getDataHandler) fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint) assert.True(t, exists) assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler) }) t.Run("custom handler registration", func(t *testing.T) { registry := NewHandlerRegistry() mockHandler := &MockHandler{ requiresVerification: true, description: "Test handler", } // Register a custom handler for a mock action const mockAction common.WebSocketAction = 99 registry.Register(mockAction, mockHandler) // Verify registration handler, exists := registry.GetHandler(mockAction) assert.True(t, exists) assert.Equal(t, mockHandler, handler) }) t.Run("unknown action", func(t *testing.T) { registry := NewHandlerRegistry() ctx := &HandlerContext{ Request: &common.HubRequest[cbor.RawMessage]{ Action: common.WebSocketAction(255), // Unknown action }, HubVerified: true, } err := registry.Handle(ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "unknown action: 255") }) t.Run("verification required", func(t *testing.T) { registry := NewHandlerRegistry() ctx := &HandlerContext{ Request: &common.HubRequest[cbor.RawMessage]{ Action: common.GetData, // Requires verification }, HubVerified: false, // Not verified } err := registry.Handle(ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "hub not verified") }) } // TestCheckFingerprintHandler tests the CheckFingerprint handler func TestCheckFingerprintHandler(t *testing.T) { handler := &CheckFingerprintHandler{} t.Run("handle with invalid data", func(t *testing.T) { client := &WebSocketClient{} ctx := &HandlerContext{ Client: client, HubVerified: false, Request: &common.HubRequest[cbor.RawMessage]{ Action: common.CheckFingerprint, Data: cbor.RawMessage{}, // Empty/invalid data }, } // Should fail to decode the fingerprint request err := handler.Handle(ctx) assert.Error(t, err) }) } ================================================ FILE: agent/health/health.go ================================================ // Package health provides functions to check and update the health of the agent. // It uses a file in the temp directory to store the timestamp of the last connection attempt. // If the timestamp is older than 90 seconds, the agent is considered unhealthy. // NB: The agent must be started with the Start() method to be considered healthy. package health import ( "errors" "log" "os" "path/filepath" "runtime" "time" ) // healthFile is the path to the health file var healthFile = getHealthFilePath() func getHealthFilePath() string { filename := "beszel_health" if runtime.GOOS == "linux" { fullPath := filepath.Join("/dev/shm", filename) if err := updateHealthFile(fullPath); err == nil { return fullPath } } return filepath.Join(os.TempDir(), filename) } func updateHealthFile(path string) error { file, err := os.Create(path) if err != nil { return err } return file.Close() } // Check checks if the agent is connected by checking the modification time of the health file func Check() error { fileInfo, err := os.Stat(healthFile) if err != nil { return err } if time.Since(fileInfo.ModTime()) > 91*time.Second { log.Println("over 90 seconds since last connection") return errors.New("unhealthy") } return nil } // Update updates the modification time of the health file func Update() error { return updateHealthFile(healthFile) } // CleanUp removes the health file func CleanUp() error { return os.Remove(healthFile) } ================================================ FILE: agent/health/health_test.go ================================================ //go:build testing package health import ( "os" "path/filepath" "testing" "time" "testing/synctest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHealth(t *testing.T) { // Override healthFile to use a temporary directory for this test. originalHealthFile := healthFile tmpDir := t.TempDir() healthFile = filepath.Join(tmpDir, "beszel_health_test") defer func() { healthFile = originalHealthFile }() t.Run("check with no health file", func(t *testing.T) { err := Check() require.Error(t, err) assert.True(t, os.IsNotExist(err), "expected a file-not-exist error, but got: %v", err) }) t.Run("update and check", func(t *testing.T) { err := Update() require.NoError(t, err, "Update() failed") err = Check() assert.NoError(t, err, "Check() failed immediately after Update()") }) // This test uses synctest to simulate time passing. t.Run("check with simulated time", func(t *testing.T) { synctest.Test(t, func(t *testing.T) { // Update the file to set the initial timestamp. require.NoError(t, Update(), "Update() failed inside synctest") // Set the mtime to the current fake time to align the file's timestamp with the simulated clock. now := time.Now() require.NoError(t, os.Chtimes(healthFile, now, now), "Chtimes failed") // Wait a duration less than the threshold. time.Sleep(89 * time.Second) synctest.Wait() // The check should still pass. assert.NoError(t, Check(), "Check() failed after 89s") // Wait for the total duration to exceed the threshold. time.Sleep(5 * time.Second) synctest.Wait() // The check should now fail as unhealthy. err := Check() require.Error(t, err, "Check() should have failed after 91s") assert.Equal(t, "unhealthy", err.Error(), "Check() returned wrong error") }) }) } ================================================ FILE: agent/lhm/beszel_lhm.cs ================================================ using System; using System.Globalization; using LibreHardwareMonitor.Hardware; class Program { static void Main() { var computer = new Computer { IsCpuEnabled = true, IsGpuEnabled = true, IsMemoryEnabled = true, IsMotherboardEnabled = true, IsStorageEnabled = true, // IsPsuEnabled = true, // IsNetworkEnabled = true, }; computer.Open(); var reader = Console.In; var writer = Console.Out; string line; while ((line = reader.ReadLine()) != null) { if (line.Trim().Equals("getTemps", StringComparison.OrdinalIgnoreCase)) { foreach (var hw in computer.Hardware) { // process main hardware sensors ProcessSensors(hw, writer); // process subhardware sensors foreach (var subhardware in hw.SubHardware) { ProcessSensors(subhardware, writer); } } // send empty line to signal end of sensor data writer.WriteLine(); writer.Flush(); } } computer.Close(); } static void ProcessSensors(IHardware hardware, System.IO.TextWriter writer) { var updated = false; foreach (var sensor in hardware.Sensors) { var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue; if (!validTemp || sensor.Name.IndexOf("Distance", StringComparison.OrdinalIgnoreCase) >= 0 || sensor.Name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 || sensor.Name.IndexOf("Critical", StringComparison.OrdinalIgnoreCase) >= 0 || sensor.Name.IndexOf("Warning", StringComparison.OrdinalIgnoreCase) >= 0 || sensor.Name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0) { continue; } if (!updated) { hardware.Update(); updated = true; } var name = sensor.Name; // if sensor.Name starts with "Temperature" replace with hardware.Identifier but retain the rest of the name. // usually this is a number like Temperature 3 if (sensor.Name.StartsWith("Temperature")) { name = hardware.Identifier.ToString().Replace("/", "_").TrimStart('_') + sensor.Name.Substring(11); } // invariant culture assures the value is parsable as a float var value = sensor.Value.Value.ToString("0.##", CultureInfo.InvariantCulture); // write the name and value to the writer writer.WriteLine($"{name}|{value}"); } } } ================================================ FILE: agent/lhm/beszel_lhm.csproj ================================================ Exe net48 x64 win-x64 false ================================================ FILE: agent/mdraid_linux.go ================================================ //go:build linux package agent import ( "fmt" "os" "path/filepath" "strconv" "strings" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/smart" ) // mdraidSysfsRoot is a test hook; production value is "/sys". var mdraidSysfsRoot = "/sys" type mdraidHealth struct { level string arrayState string degraded uint64 raidDisks uint64 syncAction string syncCompleted string syncSpeed string mismatchCnt uint64 capacity uint64 } // scanMdraidDevices discovers Linux md arrays exposed in sysfs. func scanMdraidDevices() []*DeviceInfo { blockDir := filepath.Join(mdraidSysfsRoot, "block") entries, err := os.ReadDir(blockDir) if err != nil { return nil } devices := make([]*DeviceInfo, 0, 2) for _, ent := range entries { name := ent.Name() if !isMdraidBlockName(name) { continue } mdDir := filepath.Join(blockDir, name, "md") if !utils.FileExists(filepath.Join(mdDir, "array_state")) { continue } devPath := filepath.Join("/dev", name) devices = append(devices, &DeviceInfo{ Name: devPath, Type: "mdraid", InfoName: devPath + " [mdraid]", Protocol: "MD", }) } return devices } // collectMdraidHealth reads mdraid health and stores it in SmartDataMap. func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) { if deviceInfo == nil || deviceInfo.Name == "" { return false, nil } base := filepath.Base(deviceInfo.Name) if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") { return false, nil } health, ok := readMdraidHealth(base) if !ok { return false, nil } deviceInfo.Type = "mdraid" key := fmt.Sprintf("mdraid:%s", base) status := mdraidSmartStatus(health) attrs := make([]*smart.SmartAttribute, 0, 10) if health.arrayState != "" { attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState}) } if health.level != "" { attrs = append(attrs, &smart.SmartAttribute{Name: "RaidLevel", RawString: health.level}) } if health.raidDisks > 0 { attrs = append(attrs, &smart.SmartAttribute{Name: "RaidDisks", RawValue: health.raidDisks}) } if health.degraded > 0 { attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded}) } if health.syncAction != "" { attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction}) } if health.syncCompleted != "" { attrs = append(attrs, &smart.SmartAttribute{Name: "SyncCompleted", RawString: health.syncCompleted}) } if health.syncSpeed != "" { attrs = append(attrs, &smart.SmartAttribute{Name: "SyncSpeed", RawString: health.syncSpeed}) } if health.mismatchCnt > 0 { attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt}) } sm.Lock() defer sm.Unlock() if _, exists := sm.SmartDataMap[key]; !exists { sm.SmartDataMap[key] = &smart.SmartData{} } data := sm.SmartDataMap[key] data.ModelName = "Linux MD RAID" if health.level != "" { data.ModelName = "Linux MD RAID (" + health.level + ")" } data.Capacity = health.capacity data.SmartStatus = status data.DiskName = filepath.Join("/dev", base) data.DiskType = "mdraid" data.Attributes = attrs return true, nil } // readMdraidHealth reads md array health fields from sysfs. func readMdraidHealth(blockName string) (mdraidHealth, bool) { var out mdraidHealth if !isMdraidBlockName(blockName) { return out, false } mdDir := filepath.Join(mdraidSysfsRoot, "block", blockName, "md") arrayState, okState := utils.ReadStringFileOK(filepath.Join(mdDir, "array_state")) if !okState { return out, false } out.arrayState = arrayState out.level = utils.ReadStringFile(filepath.Join(mdDir, "level")) out.syncAction = utils.ReadStringFile(filepath.Join(mdDir, "sync_action")) out.syncCompleted = utils.ReadStringFile(filepath.Join(mdDir, "sync_completed")) out.syncSpeed = utils.ReadStringFile(filepath.Join(mdDir, "sync_speed")) if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "raid_disks")); ok { out.raidDisks = val } if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok { out.degraded = val } if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok { out.mismatchCnt = val } if capBytes, ok := readMdraidBlockCapacityBytes(blockName, mdraidSysfsRoot); ok { out.capacity = capBytes } return out, true } // mdraidSmartStatus maps md state/sync signals to a SMART-like status. func mdraidSmartStatus(health mdraidHealth) string { state := strings.ToLower(strings.TrimSpace(health.arrayState)) switch state { case "inactive", "faulty", "broken", "stopped": return "FAILED" } // During rebuild/recovery, arrays are often temporarily degraded; report as // warning instead of hard failure while synchronization is in progress. syncAction := strings.ToLower(strings.TrimSpace(health.syncAction)) switch syncAction { case "resync", "recover", "reshape": return "WARNING" } if health.degraded > 0 { return "FAILED" } switch syncAction { case "check", "repair": return "WARNING" } switch state { case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly": return "PASSED" } return "UNKNOWN" } // isMdraidBlockName matches /dev/mdN-style block device names. func isMdraidBlockName(name string) bool { if !strings.HasPrefix(name, "md") { return false } suffix := strings.TrimPrefix(name, "md") if suffix == "" { return false } for _, c := range suffix { if c < '0' || c > '9' { return false } } return true } // readMdraidBlockCapacityBytes converts block size metadata into bytes. func readMdraidBlockCapacityBytes(blockName, root string) (uint64, bool) { sizePath := filepath.Join(root, "block", blockName, "size") lbsPath := filepath.Join(root, "block", blockName, "queue", "logical_block_size") sizeStr, ok := utils.ReadStringFileOK(sizePath) if !ok { return 0, false } sectors, err := strconv.ParseUint(sizeStr, 10, 64) if err != nil || sectors == 0 { return 0, false } logicalBlockSize := uint64(512) if lbsStr, ok := utils.ReadStringFileOK(lbsPath); ok { if parsed, err := strconv.ParseUint(lbsStr, 10, 64); err == nil && parsed > 0 { logicalBlockSize = parsed } } return sectors * logicalBlockSize, true } ================================================ FILE: agent/mdraid_linux_test.go ================================================ //go:build linux package agent import ( "os" "path/filepath" "testing" "github.com/henrygd/beszel/internal/entities/smart" ) func TestMdraidMockSysfsScanAndCollect(t *testing.T) { tmp := t.TempDir() prev := mdraidSysfsRoot mdraidSysfsRoot = tmp t.Cleanup(func() { mdraidSysfsRoot = prev }) mdDir := filepath.Join(tmp, "block", "md0", "md") queueDir := filepath.Join(tmp, "block", "md0", "queue") if err := os.MkdirAll(mdDir, 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(queueDir, 0o755); err != nil { t.Fatal(err) } write := func(path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatal(err) } } write(filepath.Join(mdDir, "array_state"), "active\n") write(filepath.Join(mdDir, "level"), "raid1\n") write(filepath.Join(mdDir, "raid_disks"), "2\n") write(filepath.Join(mdDir, "degraded"), "0\n") write(filepath.Join(mdDir, "sync_action"), "resync\n") write(filepath.Join(mdDir, "sync_completed"), "10%\n") write(filepath.Join(mdDir, "sync_speed"), "100M\n") write(filepath.Join(mdDir, "mismatch_cnt"), "0\n") write(filepath.Join(queueDir, "logical_block_size"), "512\n") write(filepath.Join(tmp, "block", "md0", "size"), "2048\n") devs := scanMdraidDevices() if len(devs) != 1 { t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs)) } if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" { t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0]) } sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}} ok, err := sm.collectMdraidHealth(devs[0]) if err != nil || !ok { t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err) } if len(sm.SmartDataMap) != 1 { t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap)) } var got *smart.SmartData for _, v := range sm.SmartDataMap { got = v break } if got == nil { t.Fatalf("SmartDataMap value nil") } if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" { t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName) } if got.SmartStatus != "WARNING" { t.Fatalf("SmartStatus=%q, want WARNING", got.SmartStatus) } if got.ModelName == "" || got.Capacity == 0 { t.Fatalf("identity fields = (model=%q cap=%d), want non-empty model and cap>0", got.ModelName, got.Capacity) } if len(got.Attributes) < 5 { t.Fatalf("attributes len=%d, want >= 5", len(got.Attributes)) } } func TestMdraidSmartStatus(t *testing.T) { if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" { t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got) } if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" { t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got) } if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" { t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got) } if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) } if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" { t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got) } if got := mdraidSmartStatus(mdraidHealth{arrayState: "unknown"}); got != "UNKNOWN" { t.Fatalf("mdraidSmartStatus(unknown) = %q, want UNKNOWN", got) } } ================================================ FILE: agent/mdraid_stub.go ================================================ //go:build !linux package agent func scanMdraidDevices() []*DeviceInfo { return nil } func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) { return false, nil } ================================================ FILE: agent/network.go ================================================ package agent import ( "fmt" "log/slog" "path" "strings" "time" "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" psutilNet "github.com/shirou/gopsutil/v4/net" ) // NicConfig controls inclusion/exclusion of network interfaces via the NICS env var // // Behavior mirrors SensorConfig's matching logic: // - Leading '-' means blacklist mode; otherwise whitelist mode // - Supports '*' wildcards using path.Match // - In whitelist mode with an empty list, no NICs are selected // - In blacklist mode with an empty list, all NICs are selected type NicConfig struct { nics map[string]struct{} isBlacklist bool hasWildcards bool } func newNicConfig(nicsEnvVal string) *NicConfig { cfg := &NicConfig{ nics: make(map[string]struct{}), } if strings.HasPrefix(nicsEnvVal, "-") { cfg.isBlacklist = true nicsEnvVal = nicsEnvVal[1:] } for nic := range strings.SplitSeq(nicsEnvVal, ",") { nic = strings.TrimSpace(nic) if nic != "" { cfg.nics[nic] = struct{}{} if strings.Contains(nic, "*") { cfg.hasWildcards = true } } } return cfg } // isValidNic determines if a NIC should be included based on NicConfig rules func isValidNic(nicName string, cfg *NicConfig) bool { // Empty list behavior differs by mode: blacklist: allow all; whitelist: allow none if len(cfg.nics) == 0 { return cfg.isBlacklist } // Exact match: return true if whitelist, false if blacklist if _, exactMatch := cfg.nics[nicName]; exactMatch { return !cfg.isBlacklist } // If no wildcards, return true if blacklist, false if whitelist if !cfg.hasWildcards { return cfg.isBlacklist } // Check for wildcard patterns for pattern := range cfg.nics { if !strings.Contains(pattern, "*") { continue } if match, _ := path.Match(pattern, nicName); match { return !cfg.isBlacklist } } return cfg.isBlacklist } func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) { // network stats a.ensureNetInterfacesInitialized() a.ensureNetworkInterfacesMap(systemStats) if netIO, err := psutilNet.IOCounters(true); err == nil { nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs) totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats) bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis) a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond) } } func (a *Agent) initializeNetIoStats() { // reset valid network interfaces a.netInterfaces = make(map[string]struct{}, 0) // parse NICS env var for whitelist / blacklist nicsEnvVal, nicsEnvExists := utils.GetEnv("NICS") var nicCfg *NicConfig if nicsEnvExists { nicCfg = newNicConfig(nicsEnvVal) } // get current network I/O stats and record valid interfaces if netIO, err := psutilNet.IOCounters(true); err == nil { for _, v := range netIO { if skipNetworkInterface(v, nicCfg) { continue } slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv) // store as a valid network interface a.netInterfaces[v.Name] = struct{}{} } } // Reset per-cache-time trackers and baselines so they will reinitialize on next use a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64]) a.netIoStats = make(map[uint16]system.NetIoStats) } // ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked func (a *Agent) ensureNetInterfacesInitialized() { if len(a.netInterfaces) == 0 { // if no network interfaces, initialize again // this is a fix if agent started before network is online (#466) // maybe refactor this in the future to not cache interface names at all so we // don't miss an interface that's been added after agent started in any circumstance a.initializeNetIoStats() } } // ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) { if systemStats.NetworkInterfaces == nil { systemStats.NetworkInterfaces = make(map[string][4]uint64, 0) } } // loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) { netIoStat = a.netIoStats[cacheTimeMs] if netIoStat.Time.IsZero() { netIoStat.Time = time.Now() msElapsed = 0 } else { msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds()) netIoStat.Time = time.Now() } return netIoStat, msElapsed } // sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) { tracker := a.netInterfaceDeltaTrackers[cacheTimeMs] if tracker == nil { tracker = deltatracker.NewDeltaTracker[string, uint64]() a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker } tracker.Cycle() for _, v := range netIO { if _, exists := a.netInterfaces[v.Name]; !exists { continue } totalBytesSent += v.BytesSent totalBytesRecv += v.BytesRecv var upDelta, downDelta uint64 upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name) tracker.Set(upKey, v.BytesSent) tracker.Set(downKey, v.BytesRecv) if msElapsed > 0 { if prevVal, ok := tracker.Previous(upKey); ok { var deltaBytes uint64 if v.BytesSent >= prevVal { deltaBytes = v.BytesSent - prevVal } else { deltaBytes = v.BytesSent } upDelta = deltaBytes * 1000 / msElapsed } if prevVal, ok := tracker.Previous(downKey); ok { var deltaBytes uint64 if v.BytesRecv >= prevVal { deltaBytes = v.BytesRecv - prevVal } else { deltaBytes = v.BytesRecv } downDelta = deltaBytes * 1000 / msElapsed } } systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv} } return totalBytesSent, totalBytesRecv } // computeBytesPerSecond calculates per-second totals from elapsed time and totals func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) { if msElapsed > 0 { bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed } return bytesSentPerSecond, bytesRecvPerSecond } // applyNetworkTotals validates and writes computed network stats, or resets on anomaly func (a *Agent) applyNetworkTotals( cacheTimeMs uint16, netIO []psutilNet.IOCountersStat, systemStats *system.Stats, nis system.NetIoStats, totalBytesSent, totalBytesRecv uint64, bytesSentPerSecond, bytesRecvPerSecond uint64, ) { if bytesSentPerSecond > 10_000_000_000 || bytesRecvPerSecond > 10_000_000_000 { slog.Warn("Invalid net stats. Resetting.", "sent", bytesSentPerSecond, "recv", bytesRecvPerSecond) for _, v := range netIO { if _, exists := a.netInterfaces[v.Name]; !exists { continue } slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent) } a.initializeNetIoStats() delete(a.netIoStats, cacheTimeMs) delete(a.netInterfaceDeltaTrackers, cacheTimeMs) systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0 return } systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond nis.BytesSent = totalBytesSent nis.BytesRecv = totalBytesRecv a.netIoStats[cacheTimeMs] = nis } // skipNetworkInterface returns true if the network interface should be ignored. func skipNetworkInterface(v psutilNet.IOCountersStat, nicCfg *NicConfig) bool { if nicCfg != nil { if !isValidNic(v.Name, nicCfg) { return true } // In whitelist mode, we honor explicit inclusion without auto-filtering. if !nicCfg.isBlacklist { return false } // In blacklist mode, still apply the auto-filter below. } switch { case strings.HasPrefix(v.Name, "lo"), strings.HasPrefix(v.Name, "docker"), strings.HasPrefix(v.Name, "br-"), strings.HasPrefix(v.Name, "veth"), strings.HasPrefix(v.Name, "bond"), strings.HasPrefix(v.Name, "cali"), v.BytesRecv == 0, v.BytesSent == 0: return true default: return false } } ================================================ FILE: agent/network_test.go ================================================ //go:build testing package agent import ( "testing" "time" "github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/internal/entities/system" psutilNet "github.com/shirou/gopsutil/v4/net" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsValidNic(t *testing.T) { tests := []struct { name string nicName string config *NicConfig expectedValid bool }{ { name: "Whitelist - NIC in list", nicName: "eth0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}}, isBlacklist: false, }, expectedValid: true, }, { name: "Whitelist - NIC not in list", nicName: "wlan0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}}, isBlacklist: false, }, expectedValid: false, }, { name: "Blacklist - NIC in list", nicName: "eth0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}}, isBlacklist: true, }, expectedValid: false, }, { name: "Blacklist - NIC not in list", nicName: "wlan0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}}, isBlacklist: true, }, expectedValid: true, }, { name: "Whitelist with wildcard - matching pattern", nicName: "eth1", config: &NicConfig{ nics: map[string]struct{}{"eth*": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: true, }, { name: "Whitelist with wildcard - non-matching pattern", nicName: "wlan0", config: &NicConfig{ nics: map[string]struct{}{"eth*": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: false, }, { name: "Blacklist with wildcard - matching pattern", nicName: "eth1", config: &NicConfig{ nics: map[string]struct{}{"eth*": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: false, }, { name: "Blacklist with wildcard - non-matching pattern", nicName: "wlan0", config: &NicConfig{ nics: map[string]struct{}{"eth*": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: true, }, { name: "Empty whitelist config - no NICs allowed", nicName: "eth0", config: &NicConfig{ nics: map[string]struct{}{}, isBlacklist: false, }, expectedValid: false, }, { name: "Empty blacklist config - all NICs allowed", nicName: "eth0", config: &NicConfig{ nics: map[string]struct{}{}, isBlacklist: true, }, expectedValid: true, }, { name: "Multiple patterns - exact match", nicName: "eth0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan*": {}}, isBlacklist: false, }, expectedValid: true, }, { name: "Multiple patterns - wildcard match", nicName: "wlan1", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan*": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: true, }, { name: "Multiple patterns - no match", nicName: "bond0", config: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan*": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isValidNic(tt.nicName, tt.config) assert.Equal(t, tt.expectedValid, result) }) } } func TestNewNicConfig(t *testing.T) { tests := []struct { name string nicsEnvVal string expectedCfg *NicConfig }{ { name: "Empty string", nicsEnvVal: "", expectedCfg: &NicConfig{ nics: map[string]struct{}{}, isBlacklist: false, hasWildcards: false, }, }, { name: "Single NIC whitelist", nicsEnvVal: "eth0", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth0": {}}, isBlacklist: false, hasWildcards: false, }, }, { name: "Multiple NICs whitelist", nicsEnvVal: "eth0,wlan0", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan0": {}}, isBlacklist: false, hasWildcards: false, }, }, { name: "Blacklist mode", nicsEnvVal: "-eth0,wlan0", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan0": {}}, isBlacklist: true, hasWildcards: false, }, }, { name: "With wildcards", nicsEnvVal: "eth*,wlan0", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth*": {}, "wlan0": {}}, isBlacklist: false, hasWildcards: true, }, }, { name: "Blacklist with wildcards", nicsEnvVal: "-eth*,wlan0", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth*": {}, "wlan0": {}}, isBlacklist: true, hasWildcards: true, }, }, { name: "With whitespace", nicsEnvVal: "eth0, wlan0 , eth1", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "wlan0": {}, "eth1": {}}, isBlacklist: false, hasWildcards: false, }, }, { name: "Only wildcards", nicsEnvVal: "eth*,wlan*", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth*": {}, "wlan*": {}}, isBlacklist: false, hasWildcards: true, }, }, { name: "Leading dash only", nicsEnvVal: "-", expectedCfg: &NicConfig{ nics: map[string]struct{}{}, isBlacklist: true, hasWildcards: false, }, }, { name: "Mixed exact and wildcard", nicsEnvVal: "eth0,br-*", expectedCfg: &NicConfig{ nics: map[string]struct{}{"eth0": {}, "br-*": {}}, isBlacklist: false, hasWildcards: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := newNicConfig(tt.nicsEnvVal) require.NotNil(t, cfg) assert.Equal(t, tt.expectedCfg.isBlacklist, cfg.isBlacklist) assert.Equal(t, tt.expectedCfg.hasWildcards, cfg.hasWildcards) assert.Equal(t, tt.expectedCfg.nics, cfg.nics) }) } } func TestSkipNetworkInterface(t *testing.T) { tests := []struct { name string nic psutilNet.IOCountersStat nicCfg *NicConfig expectSkip bool }{ {"loopback lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, nil, true}, {"loopback lo0", psutilNet.IOCountersStat{Name: "lo0", BytesSent: 100, BytesRecv: 100}, nil, true}, {"docker prefix", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, nil, true}, {"br- prefix", psutilNet.IOCountersStat{Name: "br-lan", BytesSent: 100, BytesRecv: 100}, nil, true}, {"veth prefix", psutilNet.IOCountersStat{Name: "veth0abc", BytesSent: 100, BytesRecv: 100}, nil, true}, {"bond prefix", psutilNet.IOCountersStat{Name: "bond0", BytesSent: 100, BytesRecv: 100}, nil, true}, {"cali prefix", psutilNet.IOCountersStat{Name: "cali1234", BytesSent: 100, BytesRecv: 100}, nil, true}, {"zero BytesRecv", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 0}, nil, true}, {"zero BytesSent", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 100}, nil, true}, {"both zero", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 0, BytesRecv: 0}, nil, true}, {"normal eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 200}, nil, false}, {"normal wlan0", psutilNet.IOCountersStat{Name: "wlan0", BytesSent: 1, BytesRecv: 1}, nil, false}, {"whitelist overrides skip (docker)", psutilNet.IOCountersStat{Name: "docker0", BytesSent: 100, BytesRecv: 100}, newNicConfig("docker0"), false}, {"whitelist overrides skip (lo)", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("lo"), false}, {"whitelist exclusion", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("eth0"), true}, {"blacklist skip lo", psutilNet.IOCountersStat{Name: "lo", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true}, {"blacklist explicit eth0", psutilNet.IOCountersStat{Name: "eth0", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), true}, {"blacklist allow eth1", psutilNet.IOCountersStat{Name: "eth1", BytesSent: 100, BytesRecv: 100}, newNicConfig("-eth0"), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expectSkip, skipNetworkInterface(tt.nic, tt.nicCfg)) }) } } func TestEnsureNetworkInterfacesMap(t *testing.T) { var a Agent var stats system.Stats // Initially nil assert.Nil(t, stats.NetworkInterfaces) // Ensure map is created a.ensureNetworkInterfacesMap(&stats) assert.NotNil(t, stats.NetworkInterfaces) // Idempotent a.ensureNetworkInterfacesMap(&stats) assert.NotNil(t, stats.NetworkInterfaces) } func TestLoadAndTickNetBaseline(t *testing.T) { a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)} // First call initializes time and returns 0 elapsed ni, elapsed := a.loadAndTickNetBaseline(100) assert.Equal(t, uint64(0), elapsed) assert.False(t, ni.Time.IsZero()) // Store back what loadAndTick returns to mimic updateNetworkStats behavior a.netIoStats[100] = ni time.Sleep(2 * time.Millisecond) // Next call should produce >= 0 elapsed and update time ni2, elapsed2 := a.loadAndTickNetBaseline(100) assert.True(t, elapsed2 > 0) assert.False(t, ni2.Time.IsZero()) } func TestComputeBytesPerSecond(t *testing.T) { a := &Agent{} // No elapsed -> zero rate bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000}) assert.Equal(t, uint64(0), bytesUp) assert.Equal(t, uint64(0), bytesDown) // With elapsed -> per-second calculation bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000}) // (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000 assert.Equal(t, uint64(10000), bytesUp) assert.Equal(t, uint64(20000), bytesDown) } func TestSumAndTrackPerNicDeltas(t *testing.T) { a := &Agent{ netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}}, netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } // Two samples for same cache interval to verify delta behavior cache := uint16(42) net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}} stats1 := &system.Stats{} a.ensureNetworkInterfacesMap(stats1) tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1) assert.Equal(t, uint64(1000), tx1) assert.Equal(t, uint64(2000), rx1) // Second cycle with elapsed, larger counters -> deltas computed inside net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}} stats := &system.Stats{} a.ensureNetworkInterfacesMap(stats) tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats) assert.Equal(t, uint64(4000), tx2) assert.Equal(t, uint64(9000), rx2) // Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000 ni, ok := stats.NetworkInterfaces["eth0"] assert.True(t, ok) assert.Equal(t, uint64(3000), ni[0]) assert.Equal(t, uint64(7000), ni[1]) } func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) { a := &Agent{ netInterfaces: map[string]struct{}{"eth0": {}}, netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } cache := uint16(77) // First interval establishes baseline values initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}} statsInitial := &system.Stats{} a.ensureNetworkInterfacesMap(statsInitial) _, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial) // Second interval increments counters normally so previous snapshot gets populated increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}} statsIncrement := &system.Stats{} a.ensureNetworkInterfacesMap(statsIncrement) _, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement) niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"] require.True(t, ok) assert.Equal(t, uint64(5_000), niIncrement[0]) assert.Equal(t, uint64(5_000), niIncrement[1]) // Third interval simulates counter reset (values drop below previous totals) reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}} statsReset := &system.Stats{} a.ensureNetworkInterfacesMap(statsReset) _, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset) niReset, ok := statsReset.NetworkInterfaces["eth0"] require.True(t, ok) assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset") assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset") } func TestApplyNetworkTotals(t *testing.T) { tests := []struct { name string bytesSentPerSecond uint64 bytesRecvPerSecond uint64 totalBytesSent uint64 totalBytesRecv uint64 expectReset bool expectedBandwidthSent uint64 expectedBandwidthRecv uint64 }{ { name: "Valid network stats - normal values", bytesSentPerSecond: 1000000, // 1 MB/s bytesRecvPerSecond: 2000000, // 2 MB/s totalBytesSent: 10000000, totalBytesRecv: 20000000, expectReset: false, expectedBandwidthSent: 1000000, expectedBandwidthRecv: 2000000, }, { name: "Invalid network stats - sent exceeds threshold", bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold bytesRecvPerSecond: 1000000, // 1 MB/s totalBytesSent: 10000000, totalBytesRecv: 20000000, expectReset: true, }, { name: "Invalid network stats - recv exceeds threshold", bytesSentPerSecond: 1000000, // 1 MB/s bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold totalBytesSent: 10000000, totalBytesRecv: 20000000, expectReset: true, }, { name: "Invalid network stats - both exceed threshold", bytesSentPerSecond: 12000000000, // ~11.4 GB/s bytesRecvPerSecond: 13000000000, // ~12.4 GB/s totalBytesSent: 10000000, totalBytesRecv: 20000000, expectReset: true, }, { name: "Zero values", bytesSentPerSecond: 0, bytesRecvPerSecond: 0, totalBytesSent: 0, totalBytesRecv: 0, expectReset: false, expectedBandwidthSent: 0, expectedBandwidthRecv: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup agent with initialized maps a := &Agent{ netInterfaces: make(map[string]struct{}), netIoStats: make(map[uint16]system.NetIoStats), netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), } cacheTimeMs := uint16(100) netIO := []psutilNet.IOCountersStat{ {Name: "eth0", BytesSent: 1000, BytesRecv: 2000}, } systemStats := &system.Stats{} nis := system.NetIoStats{} a.applyNetworkTotals( cacheTimeMs, netIO, systemStats, nis, tt.totalBytesSent, tt.totalBytesRecv, tt.bytesSentPerSecond, tt.bytesRecvPerSecond, ) if tt.expectReset { // Should have reset network tracking state - maps cleared and stats zeroed assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset") assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset") assert.Zero(t, systemStats.Bandwidth[0]) assert.Zero(t, systemStats.Bandwidth[1]) } else { // Should have applied stats assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0]) assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1]) // Should have updated NetIoStats updatedNis := a.netIoStats[cacheTimeMs] assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent) assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv) } }) } } ================================================ FILE: agent/response.go ================================================ package agent import ( "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" ) // newAgentResponse creates an AgentResponse using legacy typed fields. // This maintains backward compatibility with <= 0.17 hubs that expect specific fields. func newAgentResponse(data any, requestID *uint32) common.AgentResponse { response := common.AgentResponse{Id: requestID} switch v := data.(type) { case *system.CombinedData: response.SystemData = v case *common.FingerprintResponse: response.Fingerprint = v case string: response.String = &v case map[string]smart.SmartData: response.SmartData = v case systemd.ServiceDetails: response.ServiceInfo = v default: // For unknown types, use the generic Data field response.Data, _ = cbor.Marshal(data) } return response } ================================================ FILE: agent/sensors.go ================================================ package agent import ( "context" "fmt" "log/slog" "path" "runtime" "strconv" "strings" "unicode/utf8" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/system" "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, _ := utils.GetEnv("PRIMARY_SENSOR") sysSensors, _ := utils.GetEnv("SYS_SENSORS") sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS") skipCollection := sensorsSet && sensorsEnvVal == "" return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection) } // Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832) type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error) // newSensorConfigWithEnv creates a SensorConfig with the provided environment variables // sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string) func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig { config := &SensorConfig{ context: context.Background(), primarySensor: primarySensor, skipCollection: skipCollection, sensors: make(map[string]struct{}), } // Set sensors context (allows overriding sys location for sensors) if sysSensors != "" { slog.Info("SYS_SENSORS", "path", sysSensors) config.context = context.WithValue(config.context, common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors}, ) } // handle blacklist if strings.HasPrefix(sensorsEnvVal, "-") { config.isBlacklist = true sensorsEnvVal = sensorsEnvVal[1:] } for sensor := range strings.SplitSeq(sensorsEnvVal, ",") { sensor = strings.TrimSpace(sensor) if sensor != "" { config.sensors[sensor] = struct{}{} if strings.Contains(sensor, "*") { config.hasWildcards = true } } } return config } // updateTemperatures updates the agent with the latest sensor temperatures func (a *Agent) updateTemperatures(systemStats *system.Stats) { // skip if sensors whitelist is set to empty string if a.sensorConfig.skipCollection { slog.Debug("Skipping temperature collection") return } // reset high temp a.systemInfo.DashboardTemp = 0 temps, err := a.getTempsWithPanicRecovery(getSensorTemps) if err != nil { // retry once on panic (gopsutil/issues/1832) temps, err = a.getTempsWithPanicRecovery(getSensorTemps) if err != nil { slog.Warn("Error updating temperatures", "err", err) if len(systemStats.Temperatures) > 0 { systemStats.Temperatures = make(map[string]float64) } return } } slog.Debug("Temperature", "sensors", temps) // return if no sensors if len(temps) == 0 { return } systemStats.Temperatures = make(map[string]float64, len(temps)) for i, sensor := range temps { // check for malformed strings on darwin (gopsutil/issues/1832) if runtime.GOOS == "darwin" && !utf8.ValidString(sensor.SensorKey) { continue } // scale temperature if sensor.Temperature != 0 && sensor.Temperature < 1 { sensor.Temperature = scaleTemperature(sensor.Temperature) } // skip if temperature is unreasonable if sensor.Temperature <= 0 || sensor.Temperature >= 200 { continue } sensorName := sensor.SensorKey if _, ok := systemStats.Temperatures[sensorName]; ok { // if key already exists, append int to key sensorName = sensorName + "_" + strconv.Itoa(i) } // skip if not in whitelist or blacklist if !isValidSensor(sensorName, a.sensorConfig) { continue } // set dashboard temperature switch a.sensorConfig.primarySensor { case "": a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature) case sensorName: a.systemInfo.DashboardTemp = sensor.Temperature } systemStats.Temperatures[sensorName] = utils.TwoDecimals(sensor.Temperature) } } // getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832) func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() // get sensor data (error ignored intentionally as it may be only with one sensor) temps, _ = getTemps(a.sensorConfig.context) return } // isValidSensor checks if a sensor is valid based on the sensor name and the sensor config func isValidSensor(sensorName string, config *SensorConfig) bool { // if no sensors configured, everything is valid if len(config.sensors) == 0 { return true } // Exact match - return true if whitelist, false if blacklist if _, exactMatch := config.sensors[sensorName]; exactMatch { return !config.isBlacklist } // If no wildcards, return true if blacklist, false if whitelist if !config.hasWildcards { return config.isBlacklist } // Check for wildcard patterns for pattern := range config.sensors { if !strings.Contains(pattern, "*") { continue } if match, _ := path.Match(pattern, sensorName); match { return !config.isBlacklist } } return config.isBlacklist } // scaleTemperature scales temperatures in fractional values to reasonable Celsius values func scaleTemperature(temp float64) float64 { if temp > 1 { return temp } scaled100 := temp * 100 scaled1000 := temp * 1000 if scaled100 >= 15 && scaled100 <= 95 { return scaled100 } else if scaled1000 >= 15 && scaled1000 <= 95 { return scaled1000 } return scaled100 } ================================================ FILE: agent/sensors_default.go ================================================ //go:build !windows package agent import ( "github.com/shirou/gopsutil/v4/sensors" ) var getSensorTemps = sensors.TemperaturesWithContext ================================================ FILE: agent/sensors_test.go ================================================ //go:build testing package agent import ( "context" "fmt" "os" "testing" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/common" "github.com/shirou/gopsutil/v4/sensors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsValidSensor(t *testing.T) { tests := []struct { name string sensorName string config *SensorConfig expectedValid bool }{ { name: "Whitelist - sensor in list", sensorName: "cpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}}, isBlacklist: false, }, expectedValid: true, }, { name: "Whitelist - sensor not in list", sensorName: "gpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}}, isBlacklist: false, }, expectedValid: false, }, { name: "Blacklist - sensor in list", sensorName: "cpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}}, isBlacklist: true, }, expectedValid: false, }, { name: "Blacklist - sensor not in list", sensorName: "gpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}}, isBlacklist: true, }, expectedValid: true, }, { name: "Whitelist with wildcard - matching pattern", sensorName: "core_0_temp", config: &SensorConfig{ sensors: map[string]struct{}{"core_*_temp": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: true, }, { name: "Whitelist with wildcard - non-matching pattern", sensorName: "gpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"core_*_temp": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: false, }, { name: "Blacklist with wildcard - matching pattern", sensorName: "core_0_temp", config: &SensorConfig{ sensors: map[string]struct{}{"core_*_temp": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: false, }, { name: "Blacklist with wildcard - non-matching pattern", sensorName: "gpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"core_*_temp": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: true, }, { name: "No sensors configured", sensorName: "any_temp", config: &SensorConfig{ sensors: map[string]struct{}{}, isBlacklist: false, hasWildcards: false, skipCollection: false, }, expectedValid: true, }, { name: "Mixed patterns in whitelist - exact match", sensorName: "cpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: true, }, { name: "Mixed patterns in whitelist - wildcard match", sensorName: "core_1_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, isBlacklist: false, hasWildcards: true, }, expectedValid: true, }, { name: "Mixed patterns in blacklist - exact match", sensorName: "cpu_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: false, }, { name: "Mixed patterns in blacklist - wildcard match", sensorName: "core_1_temp", config: &SensorConfig{ sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}}, isBlacklist: true, hasWildcards: true, }, expectedValid: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isValidSensor(tt.sensorName, tt.config) assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName) }) } } func TestNewSensorConfigWithEnv(t *testing.T) { agent := &Agent{} tests := []struct { name string primarySensor string sysSensors string sensors string skipCollection bool expectedConfig *SensorConfig }{ { name: "Empty configuration", primarySensor: "", sysSensors: "", sensors: "", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "", sensors: map[string]struct{}{}, isBlacklist: false, hasWildcards: false, skipCollection: false, }, }, { name: "Explicitly set to empty string", primarySensor: "", sysSensors: "", sensors: "", skipCollection: true, expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "", sensors: map[string]struct{}{}, isBlacklist: false, hasWildcards: false, skipCollection: true, }, }, { name: "Primary sensor only - should create sensor map", primarySensor: "cpu_temp", sysSensors: "", sensors: "", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "cpu_temp", sensors: map[string]struct{}{}, isBlacklist: false, hasWildcards: false, }, }, { name: "Whitelist sensors", primarySensor: "cpu_temp", sysSensors: "", sensors: "cpu_temp,gpu_temp", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "cpu_temp", sensors: map[string]struct{}{ "cpu_temp": {}, "gpu_temp": {}, }, isBlacklist: false, hasWildcards: false, }, }, { name: "Blacklist sensors", primarySensor: "cpu_temp", sysSensors: "", sensors: "-cpu_temp,gpu_temp", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "cpu_temp", sensors: map[string]struct{}{ "cpu_temp": {}, "gpu_temp": {}, }, isBlacklist: true, hasWildcards: false, }, }, { name: "Sensors with wildcard", primarySensor: "cpu_temp", sysSensors: "", sensors: "cpu_*,gpu_temp", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "cpu_temp", sensors: map[string]struct{}{ "cpu_*": {}, "gpu_temp": {}, }, isBlacklist: false, hasWildcards: true, }, }, { name: "Sensors with whitespace", primarySensor: "cpu_temp", sysSensors: "", sensors: "cpu_*, gpu_temp", expectedConfig: &SensorConfig{ context: context.Background(), primarySensor: "cpu_temp", sensors: map[string]struct{}{ "cpu_*": {}, "gpu_temp": {}, }, isBlacklist: false, hasWildcards: true, }, }, { name: "With SYS_SENSORS path", primarySensor: "cpu_temp", sysSensors: "/custom/path", sensors: "cpu_temp", expectedConfig: &SensorConfig{ primarySensor: "cpu_temp", sensors: map[string]struct{}{ "cpu_temp": {}, }, isBlacklist: false, hasWildcards: false, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection) // Check primary sensor assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) // Check sensor map if tt.expectedConfig.sensors == nil { assert.Nil(t, result.sensors) } else { assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors)) for sensor := range tt.expectedConfig.sensors { _, exists := result.sensors[sensor] assert.True(t, exists, "Sensor %s should exist in the result", sensor) } } // Check flags assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist) assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards) // Check context if tt.sysSensors != "" { // Verify context contains correct values envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap) require.True(t, ok, "Context should contain EnvMap") sysPath, ok := envMap[common.HostSysEnvKey] require.True(t, ok, "EnvMap should contain HostSysEnvKey") assert.Equal(t, tt.sysSensors, sysPath) } }) } } func TestNewSensorConfig(t *testing.T) { // Save original environment variables originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR") originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS") originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS") // Restore environment variables after the test defer func() { // Clean up test environment variables os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR") os.Unsetenv("BESZEL_AGENT_SYS_SENSORS") os.Unsetenv("BESZEL_AGENT_SENSORS") // Restore original values if they existed if hasPrimary { os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary) } if hasSys { os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys) } if hasSensors { os.Setenv("BESZEL_AGENT_SENSORS", originalSensors) } }() // Set test environment variables os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary") os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path") os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3") agent := &Agent{} result := agent.newSensorConfig() // Verify results assert.Equal(t, "test_primary", result.primarySensor) assert.NotNil(t, result.sensors) assert.Equal(t, 3, len(result.sensors)) assert.True(t, result.hasWildcards) assert.False(t, result.isBlacklist) // Check that sys sensors path is in context envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap) require.True(t, ok, "Context should contain EnvMap") sysPath, ok := envMap[common.HostSysEnvKey] require.True(t, ok, "EnvMap should contain HostSysEnvKey") assert.Equal(t, "/test/path", sysPath) } func TestScaleTemperature(t *testing.T) { tests := []struct { name string input float64 expected float64 desc string }{ // Normal temperatures (no scaling needed) {"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"}, {"normal_room_temp", 25.0, 25.0, "Normal room temperature"}, {"high_cpu_temp", 85.0, 85.0, "High CPU temperature"}, // Zero temperature {"zero_temp", 0.0, 0.0, "Zero temperature"}, // Fractional values that should use 100x scaling {"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"}, {"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"}, {"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"}, {"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"}, {"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"}, // Fractional values that should use 1000x scaling {"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"}, {"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"}, {"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"}, {"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"}, {"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"}, // Edge cases - values outside reasonable range {"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"}, {"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"}, {"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"}, // Boundary cases around the reasonable range (15-95°C) {"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"}, {"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"}, {"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"}, {"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"}, // Values just outside reasonable range {"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"}, {"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := scaleTemperature(tt.input) assert.InDelta(t, tt.expected, result, 0.001, "scaleTemperature(%v) = %v, expected %v (%s)", tt.input, result, tt.expected, tt.desc) }) } } func TestScaleTemperatureLogic(t *testing.T) { // Test the logic flow for ambiguous cases t.Run("prefers_100x_when_both_valid", func(t *testing.T) { // 0.5 could be 50°C (100x) or 500°C (1000x) // Should prefer 100x since it's tried first and is in range result := scaleTemperature(0.5) expected := 50.0 assert.InDelta(t, expected, result, 0.001, "scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)", result, expected) }) t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) { // 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range) // Should use 1000x since 100x is below reasonable range result := scaleTemperature(0.05) expected := 50.0 assert.InDelta(t, expected, result, 0.001, "scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)", result, expected) }) t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) { // 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low) // Should default to 100x scaling result := scaleTemperature(0.005) expected := 0.5 assert.InDelta(t, expected, result, 0.001, "scaleTemperature(0.005) = %v, expected %v (should default to 100x)", result, expected) }) } func TestGetTempsWithPanicRecovery(t *testing.T) { agent := &Agent{ systemInfo: system.Info{}, sensorConfig: &SensorConfig{ context: context.Background(), }, } tests := []struct { name string getTempsFn getTempsFn expectError bool errorMsg string }{ { name: "successful_function_call", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { return []sensors.TemperatureStat{ {SensorKey: "test_sensor", Temperature: 45.0}, }, nil }, expectError: false, }, { name: "function_returns_error", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { return []sensors.TemperatureStat{ {SensorKey: "test_sensor", Temperature: 45.0}, }, fmt.Errorf("sensor error") }, expectError: false, // getTempsWithPanicRecovery ignores errors from the function }, { name: "function_panics_with_string", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { panic("test panic") }, expectError: true, errorMsg: "panic: test panic", }, { name: "function_panics_with_error", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { panic(fmt.Errorf("panic error")) }, expectError: true, errorMsg: "panic:", }, { name: "function_panics_with_index_out_of_bounds", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { slice := []int{1, 2, 3} _ = slice[10] // out of bounds panic return nil, nil }, expectError: true, errorMsg: "panic:", }, { name: "function_panics_with_any_conversion", getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) { var i any = "string" _ = i.(int) // type assertion panic return nil, nil }, expectError: true, errorMsg: "panic:", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var temps []sensors.TemperatureStat var err error // The function should not panic, regardless of what the injected function does assert.NotPanics(t, func() { temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn) }, "getTempsWithPanicRecovery should not panic") if tt.expectError { assert.Error(t, err, "Expected an error to be returned") if tt.errorMsg != "" { assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain expected text") } assert.Nil(t, temps, "Temps should be nil when panic occurs") } else { assert.NoError(t, err, "Should not return error for successful calls") } }) } } ================================================ FILE: agent/sensors_windows.go ================================================ //go:build windows //go:generate dotnet build -c Release lhm/beszel_lhm.csproj package agent import ( "bufio" "context" "embed" "errors" "fmt" "io" "log/slog" "os" "os/exec" "path" "path/filepath" "strconv" "strings" "sync" "time" "github.com/shirou/gopsutil/v4/sensors" ) // Note: This is always called from Agent.gatherStats() which holds Agent.Lock(), // so no internal concurrency protection is needed. // lhmProcess is a wrapper around the LHM .NET process. type lhmProcess struct { cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser scanner *bufio.Scanner isRunning bool stoppedNoSensors bool consecutiveNoSensors uint8 execPath string tempDir string } //go:embed all:lhm/bin/Release/net48 var lhmFs embed.FS var ( beszelLhm *lhmProcess beszelLhmOnce sync.Once useLHM = os.Getenv("LHM") == "true" ) var errNoSensors = errors.New("no sensors found (try running as admin with LHM=true)") // newlhmProcess copies the embedded LHM executable to a temporary directory and starts it. func newlhmProcess() (*lhmProcess, error) { destDir := filepath.Join(os.TempDir(), "beszel") execPath := filepath.Join(destDir, "beszel_lhm.exe") if err := os.MkdirAll(destDir, 0755); err != nil { return nil, fmt.Errorf("failed to create temp directory: %w", err) } // Only copy if executable doesn't exist if _, err := os.Stat(execPath); os.IsNotExist(err) { if err := copyEmbeddedDir(lhmFs, "lhm/bin/Release/net48", destDir); err != nil { return nil, fmt.Errorf("failed to copy embedded directory: %w", err) } } lhm := &lhmProcess{ execPath: execPath, tempDir: destDir, } if err := lhm.startProcess(); err != nil { return nil, fmt.Errorf("failed to start process: %w", err) } return lhm, nil } // startProcess starts the external LHM process func (lhm *lhmProcess) startProcess() error { // Clean up any existing process lhm.cleanupProcess() cmd := exec.Command(lhm.execPath) stdin, err := cmd.StdinPipe() if err != nil { return err } stdout, err := cmd.StdoutPipe() if err != nil { stdin.Close() return err } if err := cmd.Start(); err != nil { stdin.Close() stdout.Close() return err } // Update process state lhm.cmd = cmd lhm.stdin = stdin lhm.stdout = stdout lhm.scanner = bufio.NewScanner(stdout) lhm.isRunning = true // Give process a moment to initialize time.Sleep(100 * time.Millisecond) return nil } // cleanupProcess terminates the process and closes resources but preserves files func (lhm *lhmProcess) cleanupProcess() { lhm.isRunning = false if lhm.cmd != nil && lhm.cmd.Process != nil { lhm.cmd.Process.Kill() lhm.cmd.Wait() } if lhm.stdin != nil { lhm.stdin.Close() lhm.stdin = nil } if lhm.stdout != nil { lhm.stdout.Close() lhm.stdout = nil } lhm.cmd = nil lhm.scanner = nil lhm.stoppedNoSensors = false lhm.consecutiveNoSensors = 0 } func (lhm *lhmProcess) getTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { if !useLHM || lhm.stoppedNoSensors { // Fall back to gopsutil if we can't get sensors from LHM return sensors.TemperaturesWithContext(ctx) } // Start process if it's not running if !lhm.isRunning || lhm.stdin == nil || lhm.scanner == nil { err := lhm.startProcess() if err != nil { return temps, err } } // Send command to process _, err = fmt.Fprintln(lhm.stdin, "getTemps") if err != nil { lhm.isRunning = false return temps, fmt.Errorf("failed to send command: %w", err) } // Read all sensor lines until we hit an empty line or EOF for lhm.scanner.Scan() { line := strings.TrimSpace(lhm.scanner.Text()) if line == "" { break } parts := strings.Split(line, "|") if len(parts) != 2 { slog.Debug("Invalid sensor format", "line", line) continue } name := strings.TrimSpace(parts[0]) valueStr := strings.TrimSpace(parts[1]) value, err := strconv.ParseFloat(valueStr, 64) if err != nil { slog.Debug("Failed to parse sensor", "err", err, "line", line) continue } if name == "" || value <= 0 || value > 150 { slog.Debug("Invalid sensor", "name", name, "val", value, "line", line) continue } temps = append(temps, sensors.TemperatureStat{ SensorKey: name, Temperature: value, }) } if err := lhm.scanner.Err(); err != nil { lhm.isRunning = false return temps, err } // Handle no sensors case if len(temps) == 0 { lhm.consecutiveNoSensors++ if lhm.consecutiveNoSensors >= 3 { lhm.stoppedNoSensors = true slog.Warn(errNoSensors.Error()) lhm.cleanup() } return sensors.TemperaturesWithContext(ctx) } lhm.consecutiveNoSensors = 0 return temps, nil } // getSensorTemps attempts to pull sensor temperatures from the embedded LHM process. // NB: LibreHardwareMonitorLib requires admin privileges to access all available sensors. func getSensorTemps(ctx context.Context) (temps []sensors.TemperatureStat, err error) { defer func() { if err != nil { slog.Debug("Error reading sensors", "err", err) } }() if !useLHM { return sensors.TemperaturesWithContext(ctx) } // Initialize process once beszelLhmOnce.Do(func() { beszelLhm, err = newlhmProcess() }) if err != nil { return temps, fmt.Errorf("failed to initialize lhm: %w", err) } if beszelLhm == nil { return temps, fmt.Errorf("lhm not available") } return beszelLhm.getTemps(ctx) } // cleanup terminates the process and closes resources func (lhm *lhmProcess) cleanup() { lhm.cleanupProcess() if lhm.tempDir != "" { os.RemoveAll(lhm.tempDir) } } // copyEmbeddedDir copies the embedded directory to the destination path func copyEmbeddedDir(fs embed.FS, srcPath, destPath string) error { entries, err := fs.ReadDir(srcPath) if err != nil { return err } if err := os.MkdirAll(destPath, 0755); err != nil { return err } for _, entry := range entries { srcEntryPath := path.Join(srcPath, entry.Name()) destEntryPath := filepath.Join(destPath, entry.Name()) if entry.IsDir() { if err := copyEmbeddedDir(fs, srcEntryPath, destEntryPath); err != nil { return err } continue } data, err := fs.ReadFile(srcEntryPath) if err != nil { return err } if err := os.WriteFile(destEntryPath, data, 0755); err != nil { return err } } return nil } ================================================ FILE: agent/server.go ================================================ package agent import ( "encoding/json" "errors" "fmt" "io" "log/slog" "net" "os" "strings" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/system" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" ) // ServerOptions contains configuration options for starting the SSH server. type ServerOptions struct { Addr string // Network address to listen on (e.g., ":45876" or "/path/to/socket") Network string // Network type ("tcp" or "unix") Keys []gossh.PublicKey // SSH public keys for authentication } // hubVersions caches hub versions by session ID to avoid repeated parsing. var hubVersions map[string]semver.Version // StartServer starts the SSH server with the provided options. // It configures the server with secure defaults, sets up authentication, // and begins listening for connections. Returns an error if the server // is already running or if there's an issue starting the server. func (a *Agent) StartServer(opts ServerOptions) error { if disableSSH, _ := utils.GetEnv("DISABLE_SSH"); disableSSH == "true" { return errors.New("SSH disabled") } if a.server != nil { return errors.New("server already started") } slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network) if opts.Network == "unix" { // remove existing socket file if it exists if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) { return err } } // start listening on the address ln, err := net.Listen(opts.Network, opts.Addr) if err != nil { return err } defer ln.Close() // base config (limit to allowed algorithms) config := &gossh.ServerConfig{ ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), } config.KeyExchanges = common.DefaultKeyExchanges config.MACs = common.DefaultMACs config.Ciphers = common.DefaultCiphers // set default handler ssh.Handle(a.handleSession) a.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 { remoteAddr := ctx.RemoteAddr() for _, pubKey := range opts.Keys { if ssh.KeysEqual(key, pubKey) { slog.Info("SSH connected", "addr", remoteAddr) return true } } slog.Warn("Invalid SSH key", "addr", remoteAddr) return false }, // disable pty PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool { return false }, // close idle connections after 70 seconds IdleTimeout: 70 * time.Second, } // Start SSH server on the listener return a.server.Serve(ln) } // getHubVersion retrieves and caches the hub version for a given session. // It extracts the version from the SSH client version string and caches // it to avoid repeated parsing. Returns a zero version if parsing fails. func (a *Agent) getHubVersion(sessionId string, sessionCtx ssh.Context) semver.Version { if hubVersions == nil { hubVersions = make(map[string]semver.Version, 1) } hubVersion, ok := hubVersions[sessionId] if ok { return hubVersion } // Extract hub version from SSH client version clientVersion := sessionCtx.Value(ssh.ContextKeyClientVersion) if versionStr, ok := clientVersion.(string); ok { hubVersion, _ = extractHubVersion(versionStr) } hubVersions[sessionId] = hubVersion return hubVersion } // handleSession handles an incoming SSH session by gathering system statistics // and sending them to the hub. It signals connection events, determines the // appropriate encoding format based on hub version, and exits with appropriate // status codes. func (a *Agent) handleSession(s ssh.Session) { a.connectionManager.eventChan <- SSHConnect sessionCtx := s.Context() sessionID := sessionCtx.SessionID() hubVersion := a.getHubVersion(sessionID, sessionCtx) // Legacy one-shot behavior for older hubs if hubVersion.LT(beszel.MinVersionAgentResponse) { if err := a.handleLegacyStats(s, hubVersion); err != nil { slog.Error("Error encoding stats", "err", err) s.Exit(1) return } } var req common.HubRequest[cbor.RawMessage] if err := cbor.NewDecoder(s).Decode(&req); err != nil { // Fallback to legacy one-shot if the first decode fails if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil { slog.Error("Error encoding stats (fallback)", "err", err2) s.Exit(1) return } s.Exit(0) return } if err := a.handleSSHRequest(s, &req); err != nil { slog.Error("SSH request handling failed", "err", err) s.Exit(1) return } s.Exit(0) } // handleSSHRequest builds a handler context and dispatches to the shared registry func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMessage]) error { // SSH does not support fingerprint auth action if req.Action == common.CheckFingerprint { return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: "unsupported action"}) } // responder that writes AgentResponse to stdout // Uses legacy typed fields for backward compatibility with <= 0.17 sshResponder := func(data any, requestID *uint32) error { response := newAgentResponse(data, requestID) return cbor.NewEncoder(w).Encode(response) } ctx := &HandlerContext{ Client: nil, Agent: a, Request: req, RequestID: nil, HubVerified: true, SendResponse: sshResponder, } if handler, ok := a.handlerRegistry.GetHandler(req.Action); ok { if err := handler.Handle(ctx); err != nil { return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: err.Error()}) } return nil } return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: fmt.Sprintf("unknown action: %d", req.Action)}) } // handleLegacyStats serves the legacy one-shot stats payload for older hubs func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000}) return a.writeToSession(w, stats, hubVersion) } // writeToSession encodes and writes system statistics to the session. // It chooses between CBOR and JSON encoding based on the hub version, // using CBOR for newer versions and JSON for legacy compatibility. func (a *Agent) writeToSession(w io.Writer, stats *system.CombinedData, hubVersion semver.Version) error { if hubVersion.GTE(beszel.MinVersionCbor) { return cbor.NewEncoder(w).Encode(stats) } return json.NewEncoder(w).Encode(stats) } // extractHubVersion extracts the beszel version from SSH client version string. // Expected format: "SSH-2.0-beszel_X.Y.Z" or "beszel_X.Y.Z" func extractHubVersion(versionString string) (semver.Version, error) { _, after, _ := strings.Cut(versionString, "_") return semver.Parse(after) } // 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) ([]gossh.PublicKey, error) { var parsedKeys []gossh.PublicKey for line := range strings.Lines(input) { line = strings.TrimSpace(line) // Skip empty lines or comments if len(line) == 0 || strings.HasPrefix(line, "#") { continue } // Parse the key parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line)) if err != nil { return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err) } parsedKeys = append(parsedKeys, parsedKey) } return parsedKeys, nil } // GetAddress determines the network address to listen on from various sources. // It checks the provided address, then environment variables (LISTEN, PORT), // and finally defaults to ":45876". func GetAddress(addr string) string { if addr == "" { addr, _ = utils.GetEnv("LISTEN") } if addr == "" { // Legacy PORT environment variable support addr, _ = utils.GetEnv("PORT") } if addr == "" { return ":45876" } // prefix with : if only port was provided if GetNetwork(addr) != "unix" && !strings.Contains(addr, ":") { addr = ":" + addr } return addr } // GetNetwork determines the network type based on the address format. // It checks the NETWORK environment variable first, then infers from // the address format: addresses starting with "/" are "unix", others are "tcp". func GetNetwork(addr string) string { if network, ok := utils.GetEnv("NETWORK"); ok && network != "" { return network } if strings.HasPrefix(addr, "/") { return "unix" } return "tcp" } // StopServer stops the SSH server if it's running. // It returns an error if the server is not running or if there's an error stopping it. func (a *Agent) StopServer() error { if a.server == nil { return errors.New("SSH server not running") } slog.Info("Stopping SSH server") _ = a.server.Close() a.server = nil a.connectionManager.eventChan <- SSHDisconnect return nil } ================================================ FILE: agent/server_test.go ================================================ //go:build testing package agent import ( "context" "crypto/ed25519" "encoding/json" "fmt" "net" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" "github.com/gliderlabs/ssh" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" gossh "golang.org/x/crypto/ssh" ) func TestStartServer(t *testing.T) { // Generate a test key pair pubKey, privKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) signer, err := gossh.NewSignerFromKey(privKey) require.NoError(t, err) sshPubKey, err := gossh.NewPublicKey(pubKey) require.NoError(t, err) // Generate a different key pair for bad key test badPubKey, badPrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) badSigner, err := gossh.NewSignerFromKey(badPrivKey) require.NoError(t, err) sshBadPubKey, err := gossh.NewPublicKey(badPubKey) require.NoError(t, err) socketFile := filepath.Join(t.TempDir(), "beszel-test.sock") tests := []struct { name string config ServerOptions wantErr bool errContains string setup func() error cleanup func() error }{ { name: "tcp port only", config: ServerOptions{ Network: "tcp", Addr: ":45987", Keys: []gossh.PublicKey{sshPubKey}, }, }, { name: "tcp with ipv4", config: ServerOptions{ Network: "tcp4", Addr: "127.0.0.1:45988", Keys: []gossh.PublicKey{sshPubKey}, }, }, { name: "tcp with ipv6", config: ServerOptions{ Network: "tcp6", Addr: "[::1]:45989", Keys: []gossh.PublicKey{sshPubKey}, }, }, { name: "unix socket", config: ServerOptions{ Network: "unix", Addr: socketFile, Keys: []gossh.PublicKey{sshPubKey}, }, setup: func() error { // Create a socket file that should be removed f, err := os.Create(socketFile) if err != nil { return err } return f.Close() }, cleanup: func() error { return os.Remove(socketFile) }, }, { name: "bad key should fail", config: ServerOptions{ Network: "tcp", Addr: ":45987", Keys: []gossh.PublicKey{sshBadPubKey}, }, wantErr: true, errContains: "ssh: handshake failed", }, { name: "good key still good", config: ServerOptions{ Network: "tcp", Addr: ":45987", Keys: []gossh.PublicKey{sshPubKey}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.setup != nil { err := tt.setup() require.NoError(t, err) } if tt.cleanup != nil { defer tt.cleanup() } agent, err := NewAgent("") require.NoError(t, err) // Start server in a goroutine since it blocks errChan := make(chan error, 1) go func() { errChan <- agent.StartServer(tt.config) }() // Add a short delay to allow the server to start time.Sleep(100 * time.Millisecond) // Try to connect to verify server is running var client *gossh.Client // Choose the appropriate signer based on the test case testSigner := signer if tt.name == "bad key should fail" { testSigner = badSigner } sshClientConfig := &gossh.ClientConfig{ User: "a", Auth: []gossh.AuthMethod{ gossh.PublicKeys(testSigner), }, HostKeyCallback: gossh.InsecureIgnoreHostKey(), Timeout: 4 * time.Second, } switch tt.config.Network { case "unix": client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig) default: if !strings.Contains(tt.config.Addr, ":") { tt.config.Addr = ":" + tt.config.Addr } client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig) } if tt.wantErr { assert.Error(t, err) if tt.errContains != "" { assert.Contains(t, err.Error(), tt.errContains) } return } require.NoError(t, err) require.NotNil(t, client) client.Close() }) } } func TestStartServerDisableSSH(t *testing.T) { os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true") defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH") agent, err := NewAgent("") require.NoError(t, err) opts := ServerOptions{ Network: "tcp", Addr: ":45990", } err = agent.StartServer(opts) assert.Error(t, err) assert.Contains(t, err.Error(), "SSH disabled") } ///////////////////////////////////////////////////////////////// //////////////////// ParseKeys Tests //////////////////////////// ///////////////////////////////////////////////////////////////// // Helper function to generate a temporary file with content func createTempFile(content string) (string, error) { tmpFile, err := os.CreateTemp("", "ssh_keys_*.txt") if err != nil { return "", fmt.Errorf("failed to create temp file: %w", err) } defer tmpFile.Close() if _, err := tmpFile.WriteString(content); err != nil { return "", fmt.Errorf("failed to write to temp file: %w", err) } return tmpFile.Name(), nil } // Test case 1: String with a single SSH key func TestParseSingleKeyFromString(t *testing.T) { input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo" keys, err := ParseKeys(input) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(keys) != 1 { t.Fatalf("Expected 1 key, got %d keys", len(keys)) } if keys[0].Type() != "ssh-ed25519" { t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type()) } } // Test case 2: String with multiple SSH keys func TestParseMultipleKeysFromString(t *testing.T) { input := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D" keys, err := ParseKeys(input) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(keys) != 3 { t.Fatalf("Expected 3 keys, got %d keys", len(keys)) } if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" { t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type()) } } // Test case 3: File with a single SSH key func TestParseSingleKeyFromFile(t *testing.T) { content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo" filePath, err := createTempFile(content) if err != nil { t.Fatalf("Failed to create temp file: %v", err) } defer os.Remove(filePath) // Clean up the file after the test // Read the file content fileContent, err := os.ReadFile(filePath) if err != nil { t.Fatalf("Failed to read temp file: %v", err) } // Parse the keys keys, err := ParseKeys(string(fileContent)) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(keys) != 1 { t.Fatalf("Expected 1 key, got %d keys", len(keys)) } if keys[0].Type() != "ssh-ed25519" { t.Fatalf("Expected key type 'ssh-ed25519', got '%s'", keys[0].Type()) } } // Test case 4: File with multiple SSH keys func TestParseMultipleKeysFromFile(t *testing.T) { content := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKCBM91kukN7hbvFKtbpEeo2JXjCcNxXcdBH7V7ADMBo\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D \n #comment\n ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDMtAOQfxDlCxe+A5lVbUY/DHxK1LAF2Z3AV0FYv36D" filePath, err := createTempFile(content) if err != nil { t.Fatalf("Failed to create temp file: %v", err) } // defer os.Remove(filePath) // Clean up the file after the test // Read the file content fileContent, err := os.ReadFile(filePath) if err != nil { t.Fatalf("Failed to read temp file: %v", err) } // Parse the keys keys, err := ParseKeys(string(fileContent)) if err != nil { t.Fatalf("Expected no error, got: %v", err) } if len(keys) != 3 { t.Fatalf("Expected 3 keys, got %d keys", len(keys)) } if keys[0].Type() != "ssh-ed25519" || keys[1].Type() != "ssh-ed25519" || keys[2].Type() != "ssh-ed25519" { t.Fatalf("Unexpected key types: %s, %s, %s", keys[0].Type(), keys[1].Type(), keys[2].Type()) } } // Test case 5: Invalid SSH key input func TestParseInvalidKey(t *testing.T) { input := "invalid-key-data" _, err := ParseKeys(input) if err == nil { t.Fatalf("Expected an error for invalid key, got nil") } expectedErrMsg := "failed to parse key" if !strings.Contains(err.Error(), expectedErrMsg) { t.Fatalf("Expected error message to contain '%s', got: %v", expectedErrMsg, err) } } ///////////////////////////////////////////////////////////////// //////////////////// Hub Version Tests ////////////////////////// ///////////////////////////////////////////////////////////////// func TestExtractHubVersion(t *testing.T) { tests := []struct { name string clientVersion string expectedVersion string expectError bool }{ { name: "valid beszel client version with underscore", clientVersion: "SSH-2.0-beszel_0.11.1", expectedVersion: "0.11.1", expectError: false, }, { name: "valid beszel client version with beta", clientVersion: "SSH-2.0-beszel_1.0.0-beta", expectedVersion: "1.0.0-beta", expectError: false, }, { name: "valid beszel client version with rc", clientVersion: "SSH-2.0-beszel_0.12.0-rc1", expectedVersion: "0.12.0-rc1", expectError: false, }, { name: "different SSH client", clientVersion: "SSH-2.0-OpenSSH_8.0", expectedVersion: "8.0", expectError: true, }, { name: "malformed version string without underscore", clientVersion: "SSH-2.0-beszel", expectError: true, }, { name: "empty version string", clientVersion: "", expectError: true, }, { name: "version string with underscore but no version", clientVersion: "beszel_", expectedVersion: "", expectError: true, }, { name: "version with patch and build metadata", clientVersion: "SSH-2.0-beszel_1.2.3+build.123", expectedVersion: "1.2.3+build.123", expectError: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := extractHubVersion(tt.clientVersion) if tt.expectError { assert.Error(t, err) return } require.NoError(t, err) assert.Equal(t, tt.expectedVersion, result.String()) }) } } ///////////////////////////////////////////////////////////////// /////////////// Hub Version Detection Tests //////////////////// ///////////////////////////////////////////////////////////////// func TestGetHubVersion(t *testing.T) { agent, err := NewAgent("") require.NoError(t, err) // Mock SSH context that implements the ssh.Context interface mockCtx := &mockSSHContext{ sessionID: "test-session-123", clientVersion: "SSH-2.0-beszel_0.12.0", } // Test first call - should extract and cache version version := agent.getHubVersion("test-session-123", mockCtx) assert.Equal(t, "0.12.0", version.String()) // Test second call - should return cached version mockCtx.clientVersion = "SSH-2.0-beszel_0.11.0" // Change version but should still return cached version = agent.getHubVersion("test-session-123", mockCtx) assert.Equal(t, "0.12.0", version.String()) // Should still be cached version // Test different session - should extract new version version = agent.getHubVersion("different-session", mockCtx) assert.Equal(t, "0.11.0", version.String()) // Test with invalid version string (non-beszel client) mockCtx.clientVersion = "SSH-2.0-OpenSSH_8.0" version = agent.getHubVersion("invalid-session", mockCtx) assert.Equal(t, "0.0.0", version.String()) // Should be empty version for non-beszel clients // Test with no client version mockCtx.clientVersion = "" version = agent.getHubVersion("no-version-session", mockCtx) assert.True(t, version.EQ(semver.Version{})) // Should be empty version } // mockSSHContext implements ssh.Context for testing type mockSSHContext struct { context.Context sync.Mutex sessionID string clientVersion string } func (m *mockSSHContext) SessionID() string { return m.sessionID } func (m *mockSSHContext) ClientVersion() string { return m.clientVersion } func (m *mockSSHContext) ServerVersion() string { return "SSH-2.0-beszel_test" } func (m *mockSSHContext) Value(key interface{}) interface{} { if key == ssh.ContextKeyClientVersion { return m.clientVersion } return nil } func (m *mockSSHContext) User() string { return "test-user" } func (m *mockSSHContext) RemoteAddr() net.Addr { return nil } func (m *mockSSHContext) LocalAddr() net.Addr { return nil } func (m *mockSSHContext) Permissions() *ssh.Permissions { return nil } func (m *mockSSHContext) SetValue(key, value interface{}) {} ///////////////////////////////////////////////////////////////// /////////////// CBOR vs JSON Encoding Tests //////////////////// ///////////////////////////////////////////////////////////////// // TestWriteToSessionEncoding tests that writeToSession actually encodes data in the correct format func TestWriteToSessionEncoding(t *testing.T) { tests := []struct { name string hubVersion string expectedUsesCbor bool }{ { name: "old hub version should use JSON", hubVersion: "0.11.1", expectedUsesCbor: false, }, { name: "non-beta release should use CBOR", hubVersion: "0.12.0", expectedUsesCbor: true, }, { name: "even newer hub version should use CBOR", hubVersion: "0.16.4", expectedUsesCbor: true, }, { name: "beta version below release threshold should use JSON", hubVersion: "0.12.0-beta0", expectedUsesCbor: false, }, // { // name: "matching beta version should use CBOR", // hubVersion: "0.12.0-beta2", // expectedUsesCbor: true, // }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset the global hubVersions map to ensure clean state for each test hubVersions = nil agent, err := NewAgent("") require.NoError(t, err) // Parse the test version version, err := semver.Parse(tt.hubVersion) require.NoError(t, err) // Create test data to encode testData := createTestCombinedData() var buf strings.Builder err = agent.writeToSession(&buf, testData, version) require.NoError(t, err) encodedData := buf.String() require.NotEmpty(t, encodedData) // Verify the encoding format by attempting to decode if tt.expectedUsesCbor { var decodedCbor system.CombinedData err = cbor.Unmarshal([]byte(encodedData), &decodedCbor) assert.NoError(t, err, "Should be valid CBOR data") var decodedJson system.CombinedData err = json.Unmarshal([]byte(encodedData), &decodedJson) assert.Error(t, err, "Should not be valid JSON data") assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname) assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu) } else { // Should be JSON - try to decode as JSON var decodedJson system.CombinedData err = json.Unmarshal([]byte(encodedData), &decodedJson) assert.NoError(t, err, "Should be valid JSON data") var decodedCbor system.CombinedData err = cbor.Unmarshal([]byte(encodedData), &decodedCbor) assert.Error(t, err, "Should not be valid CBOR data") // Verify the decoded JSON data matches our test data assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname) assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu) // Verify it looks like JSON (starts with '{' and contains readable field names) assert.True(t, strings.HasPrefix(encodedData, "{"), "JSON should start with '{'") assert.Contains(t, encodedData, `"info"`, "JSON should contain readable field names") assert.Contains(t, encodedData, `"stats"`, "JSON should contain readable field names") } }) } } // Helper function to create test data for encoding tests func createTestCombinedData() *system.CombinedData { return &system.CombinedData{ Stats: system.Stats{ Cpu: 25.5, Mem: 8589934592, // 8GB MemUsed: 4294967296, // 4GB MemPct: 50.0, DiskTotal: 1099511627776, // 1TB DiskUsed: 549755813888, // 512GB DiskPct: 50.0, }, Details: &system.Details{ Hostname: "test-host", }, Info: system.Info{ Uptime: 3600, AgentVersion: "0.12.0", }, Containers: []*container.Stats{ { Name: "test-container", Cpu: 10.5, Mem: 1073741824, // 1GB }, }, } } func TestHubVersionCaching(t *testing.T) { // Reset the global hubVersions map to ensure clean state hubVersions = nil agent, err := NewAgent("") require.NoError(t, err) ctx1 := &mockSSHContext{ sessionID: "session1", clientVersion: "SSH-2.0-beszel_0.12.0", } ctx2 := &mockSSHContext{ sessionID: "session2", clientVersion: "SSH-2.0-beszel_0.11.0", } // First calls should cache the versions v1 := agent.getHubVersion("session1", ctx1) v2 := agent.getHubVersion("session2", ctx2) assert.Equal(t, "0.12.0", v1.String()) assert.Equal(t, "0.11.0", v2.String()) // Verify caching by changing context but keeping same session ID ctx1.clientVersion = "SSH-2.0-beszel_0.10.0" v1Cached := agent.getHubVersion("session1", ctx1) assert.Equal(t, "0.12.0", v1Cached.String()) // Should still be cached version // New session should get new version ctx3 := &mockSSHContext{ sessionID: "session3", clientVersion: "SSH-2.0-beszel_0.13.0", } v3 := agent.getHubVersion("session3", ctx3) assert.Equal(t, "0.13.0", v3.String()) } ================================================ FILE: agent/smart.go ================================================ //go:generate -command fetchsmartctl go run ./tools/fetchsmartctl //go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47 package agent import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/smart" ) // SmartManager manages data collection for SMART devices type SmartManager struct { sync.Mutex SmartDataMap map[string]*smart.SmartData SmartDevices []*DeviceInfo refreshMutex sync.Mutex lastScanTime time.Time smartctlPath string excludedDevices map[string]struct{} } 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"` // typeVerified reports whether we have already parsed SMART data for this device // with the stored parserType. When true we can skip re-running the detection logic. typeVerified bool // parserType holds the parser type (nvme, sat, scsi) that last succeeded. parserType string } // deviceKey is a composite key for a device, used to identify a device uniquely. type deviceKey struct { name string deviceType string } var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data // Refresh updates SMART data for all known devices func (sm *SmartManager) Refresh(forceScan bool) error { sm.refreshMutex.Lock() defer sm.refreshMutex.Unlock() scanErr := sm.ScanDevices(false) if scanErr != nil { slog.Debug("smartctl scan failed", "err", scanErr) } devices := sm.devicesSnapshot() var collectErr error for _, deviceInfo := range devices { if deviceInfo == nil { continue } if err := sm.CollectSmart(deviceInfo); err != nil { slog.Debug("smartctl collect failed", "device", deviceInfo.Name, "err", err) collectErr = err } } return sm.resolveRefreshError(scanErr, collectErr) } // devicesSnapshot returns a copy of the current device slice to avoid iterating // while holding the primary mutex for longer than necessary. func (sm *SmartManager) devicesSnapshot() []*DeviceInfo { sm.Lock() defer sm.Unlock() devices := make([]*DeviceInfo, len(sm.SmartDevices)) copy(devices, sm.SmartDevices) return devices } // hasSmartData reports whether any SMART data has been collected. // func (sm *SmartManager) hasSmartData() bool { // sm.Lock() // defer sm.Unlock() // return len(sm.SmartDataMap) > 0 // } // resolveRefreshError determines the proper error to return after a refresh. func (sm *SmartManager) resolveRefreshError(scanErr, collectErr error) error { sm.Lock() noDevices := len(sm.SmartDevices) == 0 noData := len(sm.SmartDataMap) == 0 sm.Unlock() if noDevices { if scanErr != nil { return scanErr } } if !noData { return nil } if collectErr != nil { return collectErr } if scanErr != nil { return scanErr } return errNoValidSmartData } // GetCurrentData returns the current SMART data func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData { sm.Lock() defer sm.Unlock() result := make(map[string]smart.SmartData, len(sm.SmartDataMap)) for key, value := range sm.SmartDataMap { if value != nil { 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(force bool) error { if !force && time.Since(sm.lastScanTime) < 30*time.Minute { return nil } sm.lastScanTime = time.Now() currentDevices := sm.devicesSnapshot() var configuredDevices []*DeviceInfo if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok { slog.Info("SMART_DEVICES", "value", configuredRaw) config := strings.TrimSpace(configuredRaw) if config == "" { return errNoValidSmartData } parsedDevices, err := sm.parseConfiguredDevices(config) if err != nil { return err } configuredDevices = parsedDevices } var ( scanErr error scannedDevices []*DeviceInfo hasValidScan bool ) if sm.smartctlPath != "" { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() cmd := exec.CommandContext(ctx, sm.smartctlPath, "--scan", "-j") output, err := cmd.Output() if err != nil { scanErr = err } else { scannedDevices, hasValidScan = sm.parseScan(output) if !hasValidScan { scanErr = errNoValidSmartData } } } // Add eMMC devices (Linux only) by reading sysfs health fields. This does not // require smartctl and does not scan the whole device. if emmcDevices := scanEmmcDevices(); len(emmcDevices) > 0 { scannedDevices = append(scannedDevices, emmcDevices...) hasValidScan = true } // Add Linux mdraid arrays by reading sysfs health fields. This does not // require smartctl and does not scan the whole device. if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 { scannedDevices = append(scannedDevices, raidDevices...) hasValidScan = true } finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices) finalDevices = sm.filterExcludedDevices(finalDevices) sm.updateSmartDevices(finalDevices) if len(finalDevices) == 0 { if scanErr != nil { slog.Debug("smartctl scan failed", "err", scanErr) return scanErr } return errNoValidSmartData } return nil } func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) { splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR") if splitChar == "" { splitChar = "," } entries := strings.Split(config, splitChar) devices := make([]*DeviceInfo, 0, len(entries)) for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } parts := strings.SplitN(entry, ":", 2) name := strings.TrimSpace(parts[0]) if name == "" { return nil, fmt.Errorf("invalid SMART_DEVICES entry %q", entry) } devType := "" if len(parts) == 2 { devType = strings.ToLower(strings.TrimSpace(parts[1])) } devices = append(devices, &DeviceInfo{ Name: name, Type: devType, }) } if len(devices) == 0 { return nil, errNoValidSmartData } return devices, nil } func (sm *SmartManager) refreshExcludedDevices() { rawValue, _ := utils.GetEnv("EXCLUDE_SMART") sm.excludedDevices = make(map[string]struct{}) for entry := range strings.SplitSeq(rawValue, ",") { device := strings.TrimSpace(entry) if device == "" { continue } sm.excludedDevices[device] = struct{}{} } } func (sm *SmartManager) isExcludedDevice(deviceName string) bool { _, exists := sm.excludedDevices[deviceName] return exists } func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo { if devices == nil { return []*DeviceInfo{} } excluded := sm.excludedDevices if len(excluded) == 0 { return devices } filtered := make([]*DeviceInfo, 0, len(devices)) for _, device := range devices { if device == nil || device.Name == "" { continue } if _, skip := excluded[device.Name]; skip { continue } filtered = append(filtered, device) } return filtered } // detectSmartOutputType inspects sections that are unique to each smartctl // JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used // when the reported device type is ambiguous or missing. func detectSmartOutputType(output []byte) string { var hints struct { AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"` NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"` ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"` } if err := json.Unmarshal(output, &hints); err != nil { return "" } switch { case hasJSONValue(hints.NVMeSmartHealthInformationLog): return "nvme" case hasJSONValue(hints.AtaSmartAttributes): return "sat" case hasJSONValue(hints.ScsiErrorCounterLog): return "scsi" default: return "sat" } } // hasJSONValue reports whether a JSON payload contains a concrete value. The // smartctl output often emits "null" for sections that do not apply, so we // only treat non-null content as a hint. func hasJSONValue(raw json.RawMessage) bool { if len(raw) == 0 { return false } trimmed := strings.TrimSpace(string(raw)) return trimmed != "" && trimmed != "null" } func normalizeParserType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "nvme", "sntasmedia", "sntrealtek": return "nvme" case "sat", "ata": return "sat" case "scsi": return "scsi" default: return strings.ToLower(strings.TrimSpace(value)) } } // makeDeviceKey creates a composite key from device name and type. // This allows multiple drives under the same device path (e.g., RAID controllers) // to be tracked separately. func makeDeviceKey(name, deviceType string) deviceKey { return deviceKey{name: name, deviceType: deviceType} } // parseSmartOutput attempts each SMART parser, optionally detecting the type when // it is not provided, and updates the device info when a parser succeeds. func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool { parsers := []struct { Type string Parse func([]byte) (bool, int) }{ {Type: "nvme", Parse: sm.parseSmartForNvme}, {Type: "sat", Parse: sm.parseSmartForSata}, {Type: "scsi", Parse: sm.parseSmartForScsi}, } deviceType := normalizeParserType(deviceInfo.parserType) if deviceType == "" { deviceType = normalizeParserType(deviceInfo.Type) } if deviceInfo.parserType == "" { switch deviceType { case "nvme", "sat", "scsi": deviceInfo.parserType = deviceType } } // Only run the type detection when we do not yet know which parser works // or the previous attempt failed. needsDetection := deviceType == "" || !deviceInfo.typeVerified if needsDetection { structureType := detectSmartOutputType(output) if deviceType != structureType { deviceType = structureType deviceInfo.parserType = structureType deviceInfo.typeVerified = false } if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, structureType) { deviceInfo.Type = structureType } } // Try the most likely parser first, but keep the remaining parsers in reserve // so an incorrect hint never leaves the device unparsed. selectedParsers := make([]struct { Type string Parse func([]byte) (bool, int) }, 0, len(parsers)) if deviceType != "" { for _, parser := range parsers { if parser.Type == deviceType { selectedParsers = append(selectedParsers, parser) break } } } for _, parser := range parsers { alreadySelected := false for _, selected := range selectedParsers { if selected.Type == parser.Type { alreadySelected = true break } } if alreadySelected { continue } selectedParsers = append(selectedParsers, parser) } // Try the selected parsers in order until we find one that succeeds. for _, parser := range selectedParsers { hasData, _ := parser.Parse(output) if hasData { deviceInfo.parserType = parser.Type if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, parser.Type) { deviceInfo.Type = parser.Type } // Remember that this parser is valid so future refreshes can bypass // detection entirely. deviceInfo.typeVerified = true return true } slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type) } // Leave verification false so the next pass will attempt detection again. deviceInfo.typeVerified = false slog.Debug("parsing failed", "device", deviceInfo.Name) return false } // CollectSmart collects SMART data for a device // Collect data using `smartctl -d -aj /dev/` when device type is known // Always attempts to parse output even if command fails, as some data may still be available // If collect fails, return error // If collect succeeds, parse the output and update the SmartDataMap // Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode // for initial data collection when no cached data exists func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) { return errNoValidSmartData } // mdraid health is not exposed via SMART; Linux exposes array state in sysfs. if deviceInfo != nil { if ok, err := sm.collectMdraidHealth(deviceInfo); ok { return err } } // eMMC health is not exposed via SMART on Linux, but the kernel provides // wear / EOL indicators via sysfs. Prefer that path when available. if deviceInfo != nil { if ok, err := sm.collectEmmcHealth(deviceInfo); ok { return err } } if sm.smartctlPath == "" { return errNoValidSmartData } // slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name)) // Check if we have any existing data for this device hasExistingData := sm.hasDataForDevice(deviceInfo.Name) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Try with -n standby first if we have existing data args := sm.smartctlArgs(deviceInfo, hasExistingData) cmd := exec.CommandContext(ctx, sm.smartctlPath, args...) output, err := cmd.CombinedOutput() // Check if device is in standby (exit status 2) if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 { if hasExistingData { // Device is in standby and we have cached data, keep using cache return nil } // No cached data, need to collect initial data by bypassing standby ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second) defer cancel2() args = sm.smartctlArgs(deviceInfo, false) cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...) output, err = cmd.CombinedOutput() } hasValidData := sm.parseSmartOutput(deviceInfo, output) // If NVMe controller path failed, try namespace path as fallback. // NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504 if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) { controllerPath := deviceInfo.Name namespacePath := controllerPath + "n1" if !sm.isExcludedDevice(namespacePath) { deviceInfo.Name = namespacePath ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second) defer cancel3() args = sm.smartctlArgs(deviceInfo, false) cmd = exec.CommandContext(ctx3, sm.smartctlPath, args...) output, err = cmd.CombinedOutput() hasValidData = sm.parseSmartOutput(deviceInfo, output) // Auto-exclude the controller path so future scans don't re-add it if hasValidData { sm.Lock() if sm.excludedDevices == nil { sm.excludedDevices = make(map[string]struct{}) } sm.excludedDevices[controllerPath] = struct{}{} sm.Unlock() slog.Debug("auto-excluded NVMe controller path", "path", controllerPath) } } } if !hasValidData { if err != nil { slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err) return err } slog.Info("no valid SMART data found", "device", deviceInfo.Name) return errNoValidSmartData } return nil } // smartctlArgs returns the arguments for the smartctl command // based on the device type and whether to include standby mode func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string { args := make([]string, 0, 9) var deviceType, parserType string if deviceInfo != nil { deviceType = strings.ToLower(deviceInfo.Type) parserType = strings.ToLower(deviceInfo.parserType) // types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345 if deviceType != "" && deviceType != "scsi" && deviceType != "ata" { args = append(args, "-d", deviceInfo.Type) } } args = append(args, "-a", "--json=c") effectiveType := parserType if effectiveType == "" { effectiveType = deviceType } if effectiveType == "sat" || effectiveType == "ata" { args = append(args, "-l", "devstat") } if includeStandby { args = append(args, "-n", "standby") } if deviceInfo != nil { args = append(args, deviceInfo.Name) } return args } // hasDataForDevice checks if we have cached SMART data for a specific device func (sm *SmartManager) hasDataForDevice(deviceName string) bool { sm.Lock() defer sm.Unlock() // Check if any cached data has this device name for _, data := range sm.SmartDataMap { if data != nil && data.DiskName == deviceName { return true } } return false } // parseScan parses the output of smartctl --scan -j and returns the discovered devices. func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) { scan := &scanOutput{} if err := json.Unmarshal(output, scan); err != nil { return nil, false } if len(scan.Devices) == 0 { slog.Debug("no devices found in smartctl scan") return nil, false } devices := make([]*DeviceInfo, 0, len(scan.Devices)) for _, device := range scan.Devices { slog.Debug("smartctl scan", "name", device.Name, "type", device.Type, "protocol", device.Protocol) devices = append(devices, &DeviceInfo{ Name: device.Name, Type: device.Type, InfoName: device.InfoName, Protocol: device.Protocol, }) } return devices, true } // mergeDeviceLists combines scanned and configured SMART devices, preferring // configured SMART_DEVICES when both sources reference the same device. func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo { if len(scanned) == 0 && len(configured) == 0 { return existing } // buildUniqueNameIndex returns devices that appear exactly once by name. // It is used to safely apply name-only fallbacks without RAID ambiguity. buildUniqueNameIndex := func(devices []*DeviceInfo) map[string]*DeviceInfo { counts := make(map[string]int, len(devices)) for _, dev := range devices { if dev == nil || dev.Name == "" { continue } counts[dev.Name]++ } unique := make(map[string]*DeviceInfo, len(counts)) for _, dev := range devices { if dev == nil || dev.Name == "" { continue } if counts[dev.Name] == 1 { unique[dev.Name] = dev } } return unique } // preserveVerifiedType copies the verified type/parser metadata from an existing // device record so that subsequent scans/config updates never downgrade a // previously verified device. preserveVerifiedType := func(target, prev *DeviceInfo) { if prev == nil || !prev.typeVerified { return } target.Type = prev.Type target.typeVerified = true target.parserType = prev.parserType } // applyConfiguredMetadata updates a matched device with any configured // overrides, preserving verified type data when present. applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) { // Only update the type if it has not been verified yet; otherwise we // keep the existing verified metadata intact. if configuredDev.Type != "" && !existingDev.typeVerified { newType := strings.TrimSpace(configuredDev.Type) existingDev.Type = newType existingDev.typeVerified = false existingDev.parserType = normalizeParserType(newType) } if configuredDev.InfoName != "" { existingDev.InfoName = configuredDev.InfoName } if configuredDev.Protocol != "" { existingDev.Protocol = configuredDev.Protocol } } existingIndex := make(map[deviceKey]*DeviceInfo, len(existing)) for _, dev := range existing { if dev == nil || dev.Name == "" { continue } existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev } existingByName := buildUniqueNameIndex(existing) finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured)) deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured)) // Start with the newly scanned devices so we always surface fresh metadata, // but ensure we retain any previously verified parser assignment. for _, scannedDevice := range scanned { if scannedDevice == nil || scannedDevice.Name == "" { continue } // Work on a copy so we can safely adjust metadata without mutating the // input slices that may be reused elsewhere. copyDev := *scannedDevice key := makeDeviceKey(copyDev.Name, copyDev.Type) if prev := existingIndex[key]; prev != nil { preserveVerifiedType(©Dev, prev) } else if prev := existingByName[copyDev.Name]; prev != nil { preserveVerifiedType(©Dev, prev) } finalDevices = append(finalDevices, ©Dev) copyKey := makeDeviceKey(copyDev.Name, copyDev.Type) deviceIndex[copyKey] = finalDevices[len(finalDevices)-1] } deviceIndexByName := buildUniqueNameIndex(finalDevices) // Merge configured devices on top so users can override scan results (except // for verified type information). for _, configuredDevice := range configured { if configuredDevice == nil || configuredDevice.Name == "" { continue } key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type) if existingDev, ok := deviceIndex[key]; ok { applyConfiguredMetadata(existingDev, configuredDevice) continue } if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil { applyConfiguredMetadata(existingDev, configuredDevice) continue } copyDev := *configuredDevice key = makeDeviceKey(copyDev.Name, copyDev.Type) if prev := existingIndex[key]; prev != nil { preserveVerifiedType(©Dev, prev) } else if prev := existingByName[copyDev.Name]; prev != nil { preserveVerifiedType(©Dev, prev) } else if copyDev.Type != "" { copyDev.parserType = normalizeParserType(copyDev.Type) } finalDevices = append(finalDevices, ©Dev) copyKey := makeDeviceKey(copyDev.Name, copyDev.Type) deviceIndex[copyKey] = finalDevices[len(finalDevices)-1] } return finalDevices } // updateSmartDevices replaces the cached device list and prunes SMART data // entries whose backing device no longer exists. func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) { sm.Lock() defer sm.Unlock() sm.SmartDevices = devices if len(sm.SmartDataMap) == 0 { return } validKeys := make(map[deviceKey]struct{}, len(devices)) nameCounts := make(map[string]int, len(devices)) for _, device := range devices { if device == nil || device.Name == "" { continue } validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{} nameCounts[device.Name]++ } for key, data := range sm.SmartDataMap { if data == nil { delete(sm.SmartDataMap, key) continue } if data.DiskType == "" { if nameCounts[data.DiskName] == 1 { continue } } else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok { continue } delete(sm.SmartDataMap, key) } } // isVirtualDevice checks if a device is a virtual disk that should be filtered out func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool { vendorUpper := strings.ToUpper(data.ScsiVendor) productUpper := strings.ToUpper(data.ScsiProduct) modelUpper := strings.ToUpper(data.ModelName) return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper) } // isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool { modelUpper := strings.ToUpper(data.ModelName) return sm.isVirtualDeviceFromStrings(modelUpper) } // isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool { vendorUpper := strings.ToUpper(data.ScsiVendor) productUpper := strings.ToUpper(data.ScsiProduct) modelUpper := strings.ToUpper(data.ScsiModelName) return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper) } // isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool { for _, field := range fields { fieldUpper := strings.ToUpper(field) switch { case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target strings.Contains(fieldUpper, "VIRTUAL"), strings.Contains(fieldUpper, "QEMU"), strings.Contains(fieldUpper, "VBOX"), strings.Contains(fieldUpper, "VMWARE"), strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V return true } } return false } // parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap // Returns hasValidData and exitStatus func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) { var data smart.SmartInfoForSata if err := json.Unmarshal(output, &data); err != nil { return false, 0 } if data.SerialNumber == "" { slog.Debug("no serial number", "device", data.Device.Name) return false, data.Smartctl.ExitStatus } // Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.) if sm.isVirtualDevice(&data) { slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName) return false, data.Smartctl.ExitStatus } sm.Lock() defer sm.Unlock() keyName := data.SerialNumber // if device does not exist in SmartDataMap, initialize it if _, ok := sm.SmartDataMap[keyName]; !ok { sm.SmartDataMap[keyName] = &smart.SmartData{} } // update SmartData smartData := sm.SmartDataMap[keyName] // smartData.ModelFamily = data.ModelFamily smartData.ModelName = data.ModelName smartData.SerialNumber = data.SerialNumber smartData.FirmwareVersion = data.FirmwareVersion smartData.Capacity = data.UserCapacity.Bytes smartData.Temperature = data.Temperature.Current smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.DiskName = data.Device.Name smartData.DiskType = data.Device.Type // get values from ata_device_statistics if necessary var ataDeviceStats smart.AtaDeviceStatistics if smartData.Temperature == 0 { if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil { smartData.Temperature = uint8(*temp) } } // update SmartAttributes smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table)) for _, attr := range data.AtaSmartAttributes.Table { rawValue := uint64(attr.Raw.Value) if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok { rawValue = parsed } smartAttr := &smart.SmartAttribute{ ID: attr.ID, Name: attr.Name, Value: attr.Value, Worst: attr.Worst, Threshold: attr.Thresh, RawValue: rawValue, RawString: attr.Raw.String, WhenFailed: attr.WhenFailed, } smartData.Attributes = append(smartData.Attributes, smartAttr) } sm.SmartDataMap[keyName] = smartData return true, data.Smartctl.ExitStatus } func getSmartStatus(temperature uint8, passed bool) string { if passed { return "PASSED" } else if temperature > 0 { return "FAILED" } else { return "UNKNOWN" } } // findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional // metrics can be pulled from the same structure in the future. func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 { if len(ataDeviceStats.Pages) == 0 { if len(data.AtaDeviceStatistics) == 0 { return nil } if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil { return nil } } for pageIdx := range ataDeviceStats.Pages { page := &ataDeviceStats.Pages[pageIdx] if page.Number != entryNumber { continue } for entryIdx := range page.Table { entry := &page.Table[entryIdx] if !strings.EqualFold(entry.Name, entryName) { continue } if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue { return nil } return entry.Value } } return nil } func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) { var data smart.SmartInfoForScsi if err := json.Unmarshal(output, &data); err != nil { return false, 0 } if data.SerialNumber == "" { slog.Debug("no serial number", "device", data.Device.Name) return false, data.Smartctl.ExitStatus } // Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.) if sm.isVirtualDeviceScsi(&data) { slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName) return false, data.Smartctl.ExitStatus } sm.Lock() defer sm.Unlock() keyName := data.SerialNumber if _, ok := sm.SmartDataMap[keyName]; !ok { sm.SmartDataMap[keyName] = &smart.SmartData{} } smartData := sm.SmartDataMap[keyName] smartData.ModelName = data.ScsiModelName smartData.SerialNumber = data.SerialNumber smartData.FirmwareVersion = data.ScsiRevision smartData.Capacity = data.UserCapacity.Bytes smartData.Temperature = data.Temperature.Current smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.DiskName = data.Device.Name smartData.DiskType = data.Device.Type attributes := make([]*smart.SmartAttribute, 0, 10) attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours}) attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes}) attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList}) attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles}) attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles}) attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime}) attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime}) readStats := data.ScsiErrorCounterLog.Read writeStats := data.ScsiErrorCounterLog.Write verifyStats := data.ScsiErrorCounterLog.Verify attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected}) attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors}) attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations}) if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 { attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)}) } attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected}) attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors}) attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations}) if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 { attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)}) } attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected}) attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors}) attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations}) if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 { attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)}) } smartData.Attributes = attributes sm.SmartDataMap[keyName] = smartData return true, data.Smartctl.ExitStatus } func parseScsiGigabytesProcessed(value string) int64 { if value == "" { return -1 } normalized := strings.ReplaceAll(value, ",", "") parsed, err := strconv.ParseInt(normalized, 10, 64) if err != nil { return -1 } return parsed } // parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap // Returns hasValidData and exitStatus func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) { data := &smart.SmartInfoForNvme{} if err := json.Unmarshal(output, &data); err != nil { return false, 0 } if data.SerialNumber == "" { slog.Debug("no serial number", "device", data.Device.Name) return false, data.Smartctl.ExitStatus } // Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.) if sm.isVirtualDeviceNvme(data) { slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName) return false, data.Smartctl.ExitStatus } sm.Lock() defer sm.Unlock() keyName := data.SerialNumber // if device does not exist in SmartDataMap, initialize it if _, ok := sm.SmartDataMap[keyName]; !ok { sm.SmartDataMap[keyName] = &smart.SmartData{} } // update SmartData smartData := sm.SmartDataMap[keyName] smartData.ModelName = data.ModelName smartData.SerialNumber = data.SerialNumber smartData.FirmwareVersion = data.FirmwareVersion smartData.Capacity = data.UserCapacity.Bytes smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.DiskName = data.Device.Name smartData.DiskType = data.Device.Type // nvme attributes does not follow the same format as ata attributes, // so we manually map each field to SmartAttributes log := data.NVMeSmartHealthInformationLog smartData.Attributes = []*smart.SmartAttribute{ {Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)}, {Name: "Temperature", RawValue: uint64(log.Temperature)}, {Name: "AvailableSpare", RawValue: uint64(log.AvailableSpare)}, {Name: "AvailableSpareThreshold", RawValue: uint64(log.AvailableSpareThreshold)}, {Name: "PercentageUsed", RawValue: uint64(log.PercentageUsed)}, {Name: "DataUnitsRead", RawValue: log.DataUnitsRead}, {Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten}, {Name: "HostReads", RawValue: uint64(log.HostReads)}, {Name: "HostWrites", RawValue: uint64(log.HostWrites)}, {Name: "ControllerBusyTime", RawValue: uint64(log.ControllerBusyTime)}, {Name: "PowerCycles", RawValue: uint64(log.PowerCycles)}, {Name: "PowerOnHours", RawValue: uint64(log.PowerOnHours)}, {Name: "UnsafeShutdowns", RawValue: uint64(log.UnsafeShutdowns)}, {Name: "MediaErrors", RawValue: uint64(log.MediaErrors)}, {Name: "NumErrLogEntries", RawValue: uint64(log.NumErrLogEntries)}, {Name: "WarningTempTime", RawValue: uint64(log.WarningTempTime)}, {Name: "CriticalCompTime", RawValue: uint64(log.CriticalCompTime)}, } sm.SmartDataMap[keyName] = smartData return true, data.Smartctl.ExitStatus } // detectSmartctl checks if smartctl is installed, returns an error if not func (sm *SmartManager) detectSmartctl() (string, error) { isWindows := runtime.GOOS == "windows" // Load embedded smartctl.exe for Windows amd64 builds. if isWindows && runtime.GOARCH == "amd64" { if path, err := ensureEmbeddedSmartctl(); err == nil { return path, nil } } if path, err := exec.LookPath("smartctl"); err == nil { return path, nil } locations := []string{} if isWindows { locations = append(locations, "C:\\Program Files\\smartmontools\\bin\\smartctl.exe", ) } else { locations = append(locations, "/opt/homebrew/bin/smartctl") } for _, location := range locations { if _, err := os.Stat(location); err == nil { return location, nil } } return "", errors.New("smartctl not found") } // isNvmeControllerPath checks if the path matches an NVMe controller pattern // like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1) func isNvmeControllerPath(path string) bool { base := filepath.Base(path) if !strings.HasPrefix(base, "nvme") { return false } suffix := strings.TrimPrefix(base, "nvme") if suffix == "" { return false } // Controller paths are just "nvme" + digits (e.g., nvme0, nvme1) // Namespace paths have "n" after the controller number (e.g., nvme0n1) for _, c := range suffix { if c < '0' || c > '9' { return false } } return true } // NewSmartManager creates and initializes a new SmartManager func NewSmartManager() (*SmartManager, error) { sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } sm.refreshExcludedDevices() path, err := sm.detectSmartctl() slog.Debug("smartctl", "path", path, "err", err) if err != nil { // Keep the previous fail-fast behavior unless this Linux host exposes // eMMC or mdraid health via sysfs, in which case smartctl is optional. if runtime.GOOS == "linux" { if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 { return sm, nil } } return nil, err } sm.smartctlPath = path return sm, nil } ================================================ FILE: agent/smart_nonwindows.go ================================================ //go:build !windows package agent import "errors" func ensureEmbeddedSmartctl() (string, error) { return "", errors.ErrUnsupported } ================================================ FILE: agent/smart_test.go ================================================ //go:build testing package agent import ( "errors" "os" "path/filepath" "testing" "github.com/henrygd/beszel/internal/entities/smart" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseSmartForScsi(t *testing.T) { fixturePath := filepath.Join("test-data", "smart", "scsi.json") data, err := os.ReadFile(fixturePath) if err != nil { t.Fatalf("failed reading fixture: %v", err) } sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } hasData, exitStatus := sm.parseSmartForScsi(data) if !hasData { t.Fatalf("expected SCSI data to parse successfully") } if exitStatus != 0 { t.Fatalf("expected exit status 0, got %d", exitStatus) } deviceData, ok := sm.SmartDataMap["9YHSDH9B"] if !ok { t.Fatalf("expected smart data entry for serial 9YHSDH9B") } assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204") assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B") assert.Equal(t, deviceData.FirmwareVersion, "C240") assert.Equal(t, deviceData.DiskName, "/dev/sde") assert.Equal(t, deviceData.DiskType, "scsi") assert.EqualValues(t, deviceData.Temperature, 34) assert.Equal(t, deviceData.SmartStatus, "PASSED") assert.EqualValues(t, deviceData.Capacity, 14000519643136) if len(deviceData.Attributes) == 0 { t.Fatalf("expected attributes to be populated") } assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458) assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25) assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0) assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2) assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418) assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641) assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590) assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0) } func TestParseSmartForSata(t *testing.T) { fixturePath := filepath.Join("test-data", "smart", "sda.json") data, err := os.ReadFile(fixturePath) require.NoError(t, err) sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } hasData, exitStatus := sm.parseSmartForSata(data) require.True(t, hasData) assert.Equal(t, 64, exitStatus) deviceData, ok := sm.SmartDataMap["9C40918040082"] require.True(t, ok, "expected smart data entry for serial 9C40918040082") assert.Equal(t, "P3-2TB", deviceData.ModelName) assert.Equal(t, "X0104A0", deviceData.FirmwareVersion) assert.Equal(t, "/dev/sda", deviceData.DiskName) assert.Equal(t, "sat", deviceData.DiskType) assert.Equal(t, uint8(31), deviceData.Temperature) assert.Equal(t, "PASSED", deviceData.SmartStatus) assert.Equal(t, uint64(2048408248320), deviceData.Capacity) if assert.NotEmpty(t, deviceData.Attributes) { assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31) } } func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) { jsonPayload := []byte(`{ "smartctl": {"exit_status": 0}, "device": {"name": "/dev/sdb", "type": "sat"}, "model_name": "SanDisk SSD U110 16GB", "serial_number": "DEVSTAT123", "firmware_version": "U21B001", "user_capacity": {"bytes": 16013942784}, "smart_status": {"passed": true}, "ata_smart_attributes": {"table": []}, "ata_device_statistics": { "pages": [ { "number": 5, "name": "Temperature Statistics", "table": [ {"name": "Current Temperature", "value": 22, "flags": {"valid": true}} ] } ] } }`) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} hasData, exitStatus := sm.parseSmartForSata(jsonPayload) require.True(t, hasData) assert.Equal(t, 0, exitStatus) deviceData, ok := sm.SmartDataMap["DEVSTAT123"] require.True(t, ok, "expected smart data entry for serial DEVSTAT123") assert.Equal(t, uint8(22), deviceData.Temperature) } func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) { // tests that ata_device_statistics values are parsed correctly jsonPayload := []byte(`{ "smartctl": {"exit_status": 0}, "device": {"name": "/dev/sdb", "type": "sat"}, "model_name": "SanDisk SSD U110 16GB", "serial_number": "lksjfh23lhj", "firmware_version": "U21B001", "user_capacity": {"bytes": 16013942784}, "smart_status": {"passed": true}, "ata_smart_attributes": {"table": []}, "ata_device_statistics": { "pages": [ { "number": 5, "name": "Temperature Statistics", "table": [ {"name": "Current Temperature", "value": 43, "flags": {"valid": true}}, {"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}} ] } ] } }`) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} hasData, exitStatus := sm.parseSmartForSata(jsonPayload) require.True(t, hasData) assert.Equal(t, 0, exitStatus) deviceData, ok := sm.SmartDataMap["lksjfh23lhj"] require.True(t, ok, "expected smart data entry for serial lksjfh23lhj") assert.Equal(t, uint8(43), deviceData.Temperature) } func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) { // Tests that negative values in ata_device_statistics (e.g. min operating temp) // do not cause the entire SAT parser to fail. jsonPayload := []byte(`{ "smartctl": {"exit_status": 0}, "device": {"name": "/dev/sdb", "type": "sat"}, "model_name": "SanDisk SSD U110 16GB", "serial_number": "NEGATIVE123", "firmware_version": "U21B001", "user_capacity": {"bytes": 16013942784}, "smart_status": {"passed": true}, "temperature": {"current": 38}, "ata_smart_attributes": {"table": []}, "ata_device_statistics": { "pages": [ { "number": 5, "name": "Temperature Statistics", "table": [ {"name": "Current Temperature", "value": 38, "flags": {"valid": true}}, {"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}} ] } ] } }`) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} hasData, exitStatus := sm.parseSmartForSata(jsonPayload) require.True(t, hasData) assert.Equal(t, 0, exitStatus) deviceData, ok := sm.SmartDataMap["NEGATIVE123"] require.True(t, ok, "expected smart data entry for serial NEGATIVE123") assert.Equal(t, uint8(38), deviceData.Temperature) } func TestParseSmartForSataParentheticalRawValue(t *testing.T) { jsonPayload := []byte(`{ "smartctl": {"exit_status": 0}, "device": {"name": "/dev/sdz", "type": "sat"}, "model_name": "Example", "serial_number": "PARENTHESES123", "firmware_version": "1.0", "user_capacity": {"bytes": 1024}, "smart_status": {"passed": true}, "temperature": {"current": 25}, "ata_smart_attributes": { "table": [ { "id": 9, "name": "Power_On_Hours", "value": 93, "worst": 55, "thresh": 0, "when_failed": "", "raw": { "value": 57891864217128, "string": "39925 (212 206 0)" } } ] } }`) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} hasData, exitStatus := sm.parseSmartForSata(jsonPayload) require.True(t, hasData) assert.Equal(t, 0, exitStatus) data, ok := sm.SmartDataMap["PARENTHESES123"] require.True(t, ok) require.Len(t, data.Attributes, 1) attr := data.Attributes[0] assert.Equal(t, uint64(39925), attr.RawValue) assert.Equal(t, "39925 (212 206 0)", attr.RawString) } func TestParseSmartForNvme(t *testing.T) { fixturePath := filepath.Join("test-data", "smart", "nvme0.json") data, err := os.ReadFile(fixturePath) require.NoError(t, err) sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } hasData, exitStatus := sm.parseSmartForNvme(data) require.True(t, hasData) assert.Equal(t, 0, exitStatus) deviceData, ok := sm.SmartDataMap["2024031600129"] require.True(t, ok, "expected smart data entry for serial 2024031600129") assert.Equal(t, "PELADN 512GB", deviceData.ModelName) assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion) assert.Equal(t, "/dev/nvme0", deviceData.DiskName) assert.Equal(t, "nvme", deviceData.DiskType) assert.Equal(t, uint8(61), deviceData.Temperature) assert.Equal(t, "PASSED", deviceData.SmartStatus) assert.Equal(t, uint64(512110190592), deviceData.Capacity) if assert.NotEmpty(t, deviceData.Attributes) { assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0) assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567) } } func TestHasDataForDevice(t *testing.T) { sm := &SmartManager{ SmartDataMap: map[string]*smart.SmartData{ "serial-1": {DiskName: "/dev/sda"}, "serial-2": nil, }, } assert.True(t, sm.hasDataForDevice("/dev/sda")) assert.False(t, sm.hasDataForDevice("/dev/sdb")) } func TestDevicesSnapshotReturnsCopy(t *testing.T) { originalDevice := &DeviceInfo{Name: "/dev/sda"} sm := &SmartManager{ SmartDevices: []*DeviceInfo{ originalDevice, {Name: "/dev/sdb"}, }, } snapshot := sm.devicesSnapshot() require.Len(t, snapshot, 2) sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"} assert.Equal(t, "/dev/sda", snapshot[0].Name) snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"} assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name) sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"}) assert.Len(t, snapshot, 2) } func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) { t.Setenv("SMART_DEVICES_SEPARATOR", "|") t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme") sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } err := sm.ScanDevices(true) require.NoError(t, err) require.Len(t, sm.SmartDevices, 2) assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name) assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type) assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name) assert.Equal(t, "nvme", sm.SmartDevices[1].Type) } func TestScanDevicesWithEnvOverride(t *testing.T) { t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme") sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } err := sm.ScanDevices(true) require.NoError(t, err) require.Len(t, sm.SmartDevices, 2) assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name) assert.Equal(t, "sat", sm.SmartDevices[0].Type) assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name) assert.Equal(t, "nvme", sm.SmartDevices[1].Type) } func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) { t.Setenv("SMART_DEVICES", ":sat") sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } err := sm.ScanDevices(true) require.Error(t, err) } func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) { t.Setenv("SMART_DEVICES", " ") sm := &SmartManager{ SmartDataMap: make(map[string]*smart.SmartData), } err := sm.ScanDevices(true) assert.ErrorIs(t, err, errNoValidSmartData) assert.Empty(t, sm.SmartDevices) } func TestSmartctlArgsWithoutType(t *testing.T) { device := &DeviceInfo{Name: "/dev/sda"} sm := &SmartManager{} args := sm.smartctlArgs(device, true) assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args) } func TestSmartctlArgs(t *testing.T) { sm := &SmartManager{} sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"} assert.Equal(t, []string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"}, sm.smartctlArgs(sataDevice, true), ) assert.Equal(t, []string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"}, sm.smartctlArgs(sataDevice, false), ) nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"} assert.Equal(t, []string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"}, sm.smartctlArgs(nvmeDevice, true), ) assert.Equal(t, []string{"-a", "--json=c", "-n", "standby"}, sm.smartctlArgs(nil, true), ) } func TestResolveRefreshError(t *testing.T) { scanErr := errors.New("scan failed") collectErr := errors.New("collect failed") tests := []struct { name string devices []*DeviceInfo data map[string]*smart.SmartData scanErr error collectErr error expectedErr error expectNoErr bool }{ { name: "no devices returns scan error", devices: nil, data: make(map[string]*smart.SmartData), scanErr: scanErr, expectedErr: scanErr, }, { name: "has data ignores errors", devices: []*DeviceInfo{{Name: "/dev/sda"}}, data: map[string]*smart.SmartData{"serial": {}}, scanErr: scanErr, collectErr: collectErr, expectNoErr: true, }, { name: "collect error preferred", devices: []*DeviceInfo{{Name: "/dev/sda"}}, data: make(map[string]*smart.SmartData), collectErr: collectErr, expectedErr: collectErr, }, { name: "scan error returned when no data", devices: []*DeviceInfo{{Name: "/dev/sda"}}, data: make(map[string]*smart.SmartData), scanErr: scanErr, expectedErr: scanErr, }, { name: "no errors returns sentinel", devices: []*DeviceInfo{{Name: "/dev/sda"}}, data: make(map[string]*smart.SmartData), expectedErr: errNoValidSmartData, }, { name: "no devices collect error", devices: nil, data: make(map[string]*smart.SmartData), collectErr: collectErr, expectedErr: collectErr, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sm := &SmartManager{ SmartDevices: tt.devices, SmartDataMap: tt.data, } err := sm.resolveRefreshError(tt.scanErr, tt.collectErr) if tt.expectNoErr { assert.NoError(t, err) return } if tt.expectedErr == nil { assert.NoError(t, err) } else { assert.Equal(t, tt.expectedErr, err) } }) } } func TestParseScan(t *testing.T) { sm := &SmartManager{ SmartDataMap: map[string]*smart.SmartData{ "serial-active": {DiskName: "/dev/sda"}, "serial-stale": {DiskName: "/dev/sdb"}, }, } scanJSON := []byte(`{ "devices": [ {"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"}, {"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"} ] }`) devices, hasData := sm.parseScan(scanJSON) assert.True(t, hasData) sm.updateSmartDevices(devices) require.Len(t, sm.SmartDevices, 2) assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name) assert.Equal(t, "sat", sm.SmartDevices[0].Type) assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name) assert.Equal(t, "nvme", sm.SmartDevices[1].Type) _, activeExists := sm.SmartDataMap["serial-active"] assert.True(t, activeExists, "active smart data should be preserved when device path remains") _, staleExists := sm.SmartDataMap["serial-stale"] assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears") } func TestMergeDeviceListsPrefersConfigured(t *testing.T) { scanned := []*DeviceInfo{ {Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"}, {Name: "/dev/nvme0", Type: "nvme"}, } configured := []*DeviceInfo{ {Name: "/dev/sda", Type: "sat-override"}, {Name: "/dev/sdb", Type: "sat"}, } merged := mergeDeviceLists(nil, scanned, configured) require.Len(t, merged, 3) byName := make(map[string]*DeviceInfo, len(merged)) for _, dev := range merged { byName[dev.Name] = dev } require.Contains(t, byName, "/dev/sda") assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type") assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it") require.Contains(t, byName, "/dev/nvme0") assert.Equal(t, "nvme", byName["/dev/nvme0"].Type) require.Contains(t, byName, "/dev/sdb") assert.Equal(t, "sat", byName["/dev/sdb"].Type) } func TestMergeDeviceListsPreservesVerification(t *testing.T) { existing := []*DeviceInfo{ {Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true}, } scanned := []*DeviceInfo{ {Name: "/dev/sda", Type: "nvme"}, } merged := mergeDeviceLists(existing, scanned, nil) require.Len(t, merged, 1) device := merged[0] assert.True(t, device.typeVerified) assert.Equal(t, "sat", device.parserType) assert.Equal(t, "sat+megaraid", device.Type) } func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) { existing := []*DeviceInfo{ {Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false}, } scanned := []*DeviceInfo{ {Name: "/dev/sda", Type: "nvme"}, } merged := mergeDeviceLists(existing, scanned, nil) require.Len(t, merged, 1) device := merged[0] assert.False(t, device.typeVerified) assert.Equal(t, "nvme", device.Type) assert.Equal(t, "", device.parserType) } func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) { // There are use cases where the same device name is re-used, // for example, a RAID controller with multiple drives. scanned := []*DeviceInfo{ {Name: "/dev/sda", Type: "megaraid,0"}, {Name: "/dev/sda", Type: "megaraid,1"}, {Name: "/dev/sda", Type: "megaraid,2"}, } merged := mergeDeviceLists(nil, scanned, nil) require.Len(t, merged, 3, "should have 3 separate devices for RAID controller") byKey := make(map[string]*DeviceInfo, len(merged)) for _, dev := range merged { key := dev.Name + "|" + dev.Type byKey[key] = dev } assert.Contains(t, byKey, "/dev/sda|megaraid,0") assert.Contains(t, byKey, "/dev/sda|megaraid,1") assert.Contains(t, byKey, "/dev/sda|megaraid,2") } func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) { // Test mixing RAID drives with regular devices scanned := []*DeviceInfo{ {Name: "/dev/sda", Type: "megaraid,0"}, {Name: "/dev/sda", Type: "megaraid,1"}, {Name: "/dev/sdb", Type: "sat"}, {Name: "/dev/nvme0", Type: "nvme"}, } merged := mergeDeviceLists(nil, scanned, nil) require.Len(t, merged, 4, "should have 4 separate devices") byKey := make(map[string]*DeviceInfo, len(merged)) for _, dev := range merged { key := dev.Name + "|" + dev.Type byKey[key] = dev } assert.Contains(t, byKey, "/dev/sda|megaraid,0") assert.Contains(t, byKey, "/dev/sda|megaraid,1") assert.Contains(t, byKey, "/dev/sdb|sat") assert.Contains(t, byKey, "/dev/nvme0|nvme") } func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) { // Test that updateSmartDevices correctly validates RAID drives using composite keys sm := &SmartManager{ SmartDevices: []*DeviceInfo{ {Name: "/dev/sda", Type: "megaraid,0"}, {Name: "/dev/sda", Type: "megaraid,1"}, }, SmartDataMap: map[string]*smart.SmartData{ "serial-0": { DiskName: "/dev/sda", DiskType: "megaraid,0", SerialNumber: "serial-0", }, "serial-1": { DiskName: "/dev/sda", DiskType: "megaraid,1", SerialNumber: "serial-1", }, "serial-stale": { DiskName: "/dev/sda", DiskType: "megaraid,2", SerialNumber: "serial-stale", }, }, } sm.updateSmartDevices(sm.SmartDevices) // serial-0 and serial-1 should be preserved (matching devices exist) assert.Contains(t, sm.SmartDataMap, "serial-0") assert.Contains(t, sm.SmartDataMap, "serial-1") // serial-stale should be removed (no matching device) assert.NotContains(t, sm.SmartDataMap, "serial-stale") } func TestParseSmartOutputMarksVerified(t *testing.T) { fixturePath := filepath.Join("test-data", "smart", "nvme0.json") data, err := os.ReadFile(fixturePath) require.NoError(t, err) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} device := &DeviceInfo{Name: "/dev/nvme0"} require.True(t, sm.parseSmartOutput(device, data)) assert.Equal(t, "nvme", device.Type) assert.Equal(t, "nvme", device.parserType) assert.True(t, device.typeVerified) } func TestParseSmartOutputKeepsCustomType(t *testing.T) { fixturePath := filepath.Join("test-data", "smart", "sda.json") data, err := os.ReadFile(fixturePath) require.NoError(t, err) sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"} require.True(t, sm.parseSmartOutput(device, data)) assert.Equal(t, "sat+megaraid", device.Type) assert.Equal(t, "sat", device.parserType) assert.True(t, device.typeVerified) } func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) { sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)} device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true} assert.False(t, sm.parseSmartOutput(device, []byte("not json"))) assert.False(t, device.typeVerified) assert.Equal(t, "sat", device.parserType) } func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) { t.Helper() attr := findAttr(attributes, name) if attr == nil { t.Fatalf("expected attribute %s to be present", name) } if attr.RawValue != expected { t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected) } } func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute { for _, attr := range attributes { if attr != nil && attr.Name == name { return attr } } return nil } func TestIsVirtualDevice(t *testing.T) { sm := &SmartManager{} tests := []struct { name string vendor string product string model string expected bool }{ {"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false}, {"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true}, {"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true}, {"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true}, {"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true}, {"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true}, {"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := &smart.SmartInfoForSata{ ScsiVendor: tt.vendor, ScsiProduct: tt.product, ModelName: tt.model, } result := sm.isVirtualDevice(data) assert.Equal(t, tt.expected, result) }) } } func TestIsVirtualDeviceNvme(t *testing.T) { sm := &SmartManager{} tests := []struct { name string model string expected bool }{ {"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false}, {"qemu virtual", "QEMU NVMe Ctrl", true}, {"virtualbox virtual", "VBOX NVMe", true}, {"vmware virtual", "VMWARE NVMe", true}, {"virtual in model", "Virtual NVMe Device", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := &smart.SmartInfoForNvme{ ModelName: tt.model, } result := sm.isVirtualDeviceNvme(data) assert.Equal(t, tt.expected, result) }) } } func TestIsVirtualDeviceScsi(t *testing.T) { sm := &SmartManager{} tests := []struct { name string vendor string product string model string expected bool }{ {"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false}, {"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true}, {"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true}, {"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true}, {"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true}, {"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true}, {"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { data := &smart.SmartInfoForScsi{ ScsiVendor: tt.vendor, ScsiProduct: tt.product, ScsiModelName: tt.model, } result := sm.isVirtualDeviceScsi(data) assert.Equal(t, tt.expected, result) }) } } func TestFindAtaDeviceStatisticsValue(t *testing.T) { val42 := int64(42) val100 := int64(100) valMinus20 := int64(-20) tests := []struct { name string data smart.SmartInfoForSata ataDeviceStats smart.AtaDeviceStatistics entryNumber uint8 entryName string minValue int64 maxValue int64 expectedValue *int64 }{ { name: "value in ataDeviceStats", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Current Temperature", Value: &val42}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 100, expectedValue: &val42, }, { name: "value unmarshaled from data", data: smart.SmartInfoForSata{ AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`), }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 255, expectedValue: &val100, }, { name: "value out of range (too high)", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Current Temperature", Value: &val100}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 50, expectedValue: nil, }, { name: "value out of range (too low)", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Min Temp", Value: &valMinus20}, }, }, }, }, entryNumber: 5, entryName: "Min Temp", minValue: 0, maxValue: 100, expectedValue: nil, }, { name: "no statistics available", data: smart.SmartInfoForSata{}, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 255, expectedValue: nil, }, { name: "wrong page number", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 1, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Current Temperature", Value: &val42}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 100, expectedValue: nil, }, { name: "wrong entry name", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Other Stat", Value: &val42}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 100, expectedValue: nil, }, { name: "case insensitive name match", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "CURRENT TEMPERATURE", Value: &val42}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 100, expectedValue: &val42, }, { name: "entry value is nil", ataDeviceStats: smart.AtaDeviceStatistics{ Pages: []smart.AtaDeviceStatisticsPage{ { Number: 5, Table: []smart.AtaDeviceStatisticsEntry{ {Name: "Current Temperature", Value: nil}, }, }, }, }, entryNumber: 5, entryName: "Current Temperature", minValue: 0, maxValue: 100, expectedValue: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue) if tt.expectedValue == nil { assert.Nil(t, result) } else { require.NotNil(t, result) assert.Equal(t, *tt.expectedValue, *result) } }) } } func TestRefreshExcludedDevices(t *testing.T) { tests := []struct { name string envValue string expectedDevs map[string]struct{} }{ { name: "empty env", envValue: "", expectedDevs: map[string]struct{}{}, }, { name: "single device", envValue: "/dev/sda", expectedDevs: map[string]struct{}{ "/dev/sda": {}, }, }, { name: "multiple devices", envValue: "/dev/sda,/dev/sdb,/dev/nvme0", expectedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/sdb": {}, "/dev/nvme0": {}, }, }, { name: "devices with whitespace", envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ", expectedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/sdb": {}, "/dev/nvme0": {}, }, }, { name: "duplicate devices", envValue: "/dev/sda,/dev/sdb,/dev/sda", expectedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/sdb": {}, }, }, { name: "empty entries and whitespace", envValue: "/dev/sda,, /dev/sdb , , ", expectedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/sdb": {}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.envValue != "" { t.Setenv("EXCLUDE_SMART", tt.envValue) } else { // Ensure env var is not set for empty test os.Unsetenv("EXCLUDE_SMART") } sm := &SmartManager{} sm.refreshExcludedDevices() assert.Equal(t, tt.expectedDevs, sm.excludedDevices) }) } } func TestIsExcludedDevice(t *testing.T) { sm := &SmartManager{ excludedDevices: map[string]struct{}{ "/dev/sda": {}, "/dev/nvme0": {}, }, } tests := []struct { name string deviceName string expectedBool bool }{ {"excluded device sda", "/dev/sda", true}, {"excluded device nvme0", "/dev/nvme0", true}, {"non-excluded device sdb", "/dev/sdb", false}, {"non-excluded device nvme1", "/dev/nvme1", false}, {"empty device name", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := sm.isExcludedDevice(tt.deviceName) assert.Equal(t, tt.expectedBool, result) }) } } func TestFilterExcludedDevices(t *testing.T) { tests := []struct { name string excludedDevs map[string]struct{} inputDevices []*DeviceInfo expectedDevs []*DeviceInfo expectedLength int }{ { name: "no exclusions", excludedDevs: map[string]struct{}{}, inputDevices: []*DeviceInfo{ {Name: "/dev/sda"}, {Name: "/dev/sdb"}, {Name: "/dev/nvme0"}, }, expectedDevs: []*DeviceInfo{ {Name: "/dev/sda"}, {Name: "/dev/sdb"}, {Name: "/dev/nvme0"}, }, expectedLength: 3, }, { name: "some devices excluded", excludedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/nvme0": {}, }, inputDevices: []*DeviceInfo{ {Name: "/dev/sda"}, {Name: "/dev/sdb"}, {Name: "/dev/nvme0"}, {Name: "/dev/nvme1"}, }, expectedDevs: []*DeviceInfo{ {Name: "/dev/sdb"}, {Name: "/dev/nvme1"}, }, expectedLength: 2, }, { name: "all devices excluded", excludedDevs: map[string]struct{}{ "/dev/sda": {}, "/dev/sdb": {}, }, inputDevices: []*DeviceInfo{ {Name: "/dev/sda"}, {Name: "/dev/sdb"}, }, expectedDevs: []*DeviceInfo{}, expectedLength: 0, }, { name: "nil devices", excludedDevs: map[string]struct{}{}, inputDevices: nil, expectedDevs: []*DeviceInfo{}, expectedLength: 0, }, { name: "filter nil and empty name devices", excludedDevs: map[string]struct{}{ "/dev/sda": {}, }, inputDevices: []*DeviceInfo{ {Name: "/dev/sda"}, nil, {Name: ""}, {Name: "/dev/sdb"}, }, expectedDevs: []*DeviceInfo{ {Name: "/dev/sdb"}, }, expectedLength: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sm := &SmartManager{ excludedDevices: tt.excludedDevs, } result := sm.filterExcludedDevices(tt.inputDevices) assert.Len(t, result, tt.expectedLength) assert.Equal(t, tt.expectedDevs, result) }) } } func TestIsNvmeControllerPath(t *testing.T) { tests := []struct { path string expected bool }{ // Controller paths (should return true) {"/dev/nvme0", true}, {"/dev/nvme1", true}, {"/dev/nvme10", true}, {"nvme0", true}, // Namespace paths (should return false) {"/dev/nvme0n1", false}, {"/dev/nvme1n1", false}, {"/dev/nvme0n1p1", false}, {"nvme0n1", false}, // Non-NVMe paths (should return false) {"/dev/sda", false}, {"/dev/sda1", false}, {"/dev/hda", false}, {"", false}, {"/dev/nvme", false}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { result := isNvmeControllerPath(tt.path) assert.Equal(t, tt.expected, result, "path: %s", tt.path) }) } } ================================================ FILE: agent/smart_windows.go ================================================ //go:build windows package agent import ( _ "embed" "fmt" "os" "path/filepath" "sync" ) //go:embed smartmontools/smartctl.exe var embeddedSmartctl []byte var ( smartctlOnce sync.Once smartctlPath string smartctlErr error ) func ensureEmbeddedSmartctl() (string, error) { smartctlOnce.Do(func() { destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools") if err := os.MkdirAll(destDir, 0o755); err != nil { smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err) return } destPath := filepath.Join(destDir, "smartctl.exe") if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil { smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err) return } smartctlPath = destPath }) return smartctlPath, smartctlErr } ================================================ FILE: agent/system.go ================================================ package agent import ( "bufio" "errors" "fmt" "log/slog" "os" "runtime" "strings" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/agent/battery" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/agent/zfs" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/load" "github.com/shirou/gopsutil/v4/mem" ) // prevDisk stores previous per-device disk counters for a given cache interval type prevDisk struct { readBytes uint64 writeBytes uint64 at time.Time } // Sets initial / non-changing values about the host system func (a *Agent) refreshSystemDetails() { a.systemInfo.AgentVersion = beszel.Version // get host info from Docker if available var hostInfo container.HostInfo if a.dockerManager != nil { a.systemDetails.Podman = a.dockerManager.IsPodman() hostInfo, _ = a.dockerManager.GetHostInfo() } a.systemDetails.Hostname, _ = os.Hostname() if arch, err := host.KernelArch(); err == nil { a.systemDetails.Arch = arch } else { a.systemDetails.Arch = runtime.GOARCH } platform, _, version, _ := host.PlatformInformation() if platform == "darwin" { a.systemDetails.Os = system.Darwin a.systemDetails.OsName = fmt.Sprintf("macOS %s", version) } else if strings.Contains(platform, "indows") { a.systemDetails.Os = system.Windows a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1) a.systemDetails.Kernel = version } else if platform == "freebsd" { a.systemDetails.Os = system.Freebsd a.systemDetails.Kernel, _ = host.KernelVersion() if prettyName, err := getOsPrettyName(); err == nil { a.systemDetails.OsName = prettyName } else { a.systemDetails.OsName = "FreeBSD" } } else { a.systemDetails.Os = system.Linux a.systemDetails.OsName = hostInfo.OperatingSystem if a.systemDetails.OsName == "" { if prettyName, err := getOsPrettyName(); err == nil { a.systemDetails.OsName = prettyName } else { a.systemDetails.OsName = platform } } a.systemDetails.Kernel = hostInfo.KernelVersion if a.systemDetails.Kernel == "" { a.systemDetails.Kernel, _ = host.KernelVersion() } } // cpu model if info, err := cpu.Info(); err == nil && len(info) > 0 { a.systemDetails.CpuModel = info[0].ModelName } // cores / threads cores, _ := cpu.Counts(false) threads := hostInfo.NCPU if threads == 0 { threads, _ = cpu.Counts(true) } // in lxc, logical cores reflects container limits, so use that as cores if lower if threads > 0 && threads < cores { cores = threads } a.systemDetails.Cores = cores a.systemDetails.Threads = threads // total memory a.systemDetails.MemoryTotal = hostInfo.MemTotal if a.systemDetails.MemoryTotal == 0 { if v, err := mem.VirtualMemory(); err == nil { a.systemDetails.MemoryTotal = v.Total } } // zfs if _, err := zfs.ARCSize(); err != nil { slog.Debug("Not monitoring ZFS ARC", "err", err) } else { a.zfs = true } } // Returns current info, stats about the host system func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { var systemStats system.Stats // battery if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil { systemStats.Battery[0] = batteryPercent systemStats.Battery[1] = batteryState } // cpu metrics cpuMetrics, err := getCpuMetrics(cacheTimeMs) if err == nil { systemStats.Cpu = utils.TwoDecimals(cpuMetrics.Total) systemStats.CpuBreakdown = []float64{ utils.TwoDecimals(cpuMetrics.User), utils.TwoDecimals(cpuMetrics.System), utils.TwoDecimals(cpuMetrics.Iowait), utils.TwoDecimals(cpuMetrics.Steal), utils.TwoDecimals(cpuMetrics.Idle), } } else { slog.Error("Error getting cpu metrics", "err", err) } // per-core cpu usage if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil { systemStats.CpuCoresUsage = perCoreUsage } // load average if avgstat, err := load.Avg(); err == nil { systemStats.LoadAvg[0] = avgstat.Load1 systemStats.LoadAvg[1] = avgstat.Load5 systemStats.LoadAvg[2] = avgstat.Load15 slog.Debug("Load average", "5m", avgstat.Load5, "15m", avgstat.Load15) } else { slog.Error("Error getting load average", "err", err) } // memory if v, err := mem.VirtualMemory(); err == nil { // swap systemStats.Swap = utils.BytesToGigabytes(v.SwapTotal) systemStats.SwapUsed = utils.BytesToGigabytes(v.SwapTotal - v.SwapFree - v.SwapCached) // cache + buffers value for default mem calculation // note: gopsutil automatically adds SReclaimable to v.Cached cacheBuff := v.Cached + v.Buffers - v.Shared if cacheBuff <= 0 { cacheBuff = max(v.Total-v.Free-v.Used, 0) } // htop memory calculation overrides (likely outdated as of mid 2025) if a.memCalc == "htop" { // cacheBuff = v.Cached + v.Buffers - v.Shared v.Used = v.Total - (v.Free + cacheBuff) v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 } // if a.memCalc == "legacy" { // v.Used = v.Total - v.Free - v.Buffers - v.Cached // cacheBuff = v.Total - v.Free - v.Used // v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 // } // subtract ZFS ARC size from used memory and add as its own category if a.zfs { if arcSize, _ := zfs.ARCSize(); arcSize > 0 && arcSize < v.Used { v.Used = v.Used - arcSize v.UsedPercent = float64(v.Used) / float64(v.Total) * 100.0 systemStats.MemZfsArc = utils.BytesToGigabytes(arcSize) } } systemStats.Mem = utils.BytesToGigabytes(v.Total) systemStats.MemBuffCache = utils.BytesToGigabytes(cacheBuff) systemStats.MemUsed = utils.BytesToGigabytes(v.Used) systemStats.MemPct = utils.TwoDecimals(v.UsedPercent) } // disk usage a.updateDiskUsage(&systemStats) // disk i/o (cache-aware per interval) a.updateDiskIo(cacheTimeMs, &systemStats) // network stats (per cache interval) a.updateNetworkStats(cacheTimeMs, &systemStats) // temperatures // TODO: maybe refactor to methods on systemStats a.updateTemperatures(&systemStats) // GPU data if a.gpuManager != nil { // reset high gpu percent a.systemInfo.GpuPct = 0 // get current GPU data if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 { systemStats.GPUData = gpuData // add temperatures 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.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 } } } // update system info a.systemInfo.ConnectionType = a.connectionManager.ConnectionType a.systemInfo.Cpu = systemStats.Cpu a.systemInfo.LoadAvg = systemStats.LoadAvg a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.Battery = systemStats.Battery a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.Threads = a.systemDetails.Threads return systemStats } // getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems func getOsPrettyName() (string, error) { file, err := os.Open("/etc/os-release") if err != nil { return "", err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok { value := after value = strings.Trim(value, `"`) return value, nil } } return "", errors.New("pretty name not found") } ================================================ FILE: agent/systemd.go ================================================ //go:build linux package agent import ( "context" "errors" "log/slog" "maps" "math" "os" "strconv" "strings" "sync" "time" "github.com/coreos/go-systemd/v22/dbus" "github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/internal/entities/systemd" ) var errNoActiveTime = errors.New("no active time") // systemdManager manages the collection of systemd service statistics. type systemdManager struct { sync.Mutex serviceStatsMap map[string]*systemd.Service isRunning bool hasFreshStats bool patterns []string } // isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548) func isSystemdAvailable() bool { paths := []string{ "/run/systemd/system", "/run/dbus/system_bus_socket", "/var/run/dbus/system_bus_socket", } for _, path := range paths { if _, err := os.Stat(path); err == nil { return true } } if data, err := os.ReadFile("/proc/1/comm"); err == nil { return strings.TrimSpace(string(data)) == "systemd" } return false } // newSystemdManager creates a new systemdManager. func newSystemdManager() (*systemdManager, error) { if skipSystemd, _ := utils.GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" { return nil, nil } // Check if systemd is available on the system before attempting connection if !isSystemdAvailable() { slog.Debug("Systemd not available") return nil, nil } conn, err := dbus.NewSystemConnectionContext(context.Background()) if err != nil { slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd") return nil, err } manager := &systemdManager{ serviceStatsMap: make(map[string]*systemd.Service), patterns: getServicePatterns(), } manager.startWorker(conn) return manager, nil } func (sm *systemdManager) startWorker(conn *dbus.Conn) { if sm.isRunning { return } sm.isRunning = true // prime the service stats map with the current services _ = sm.getServiceStats(conn, true) // update the services every 10 minutes go func() { for { time.Sleep(time.Minute * 10) _ = sm.getServiceStats(nil, true) } }() } // getServiceStatsCount returns the number of systemd services. func (sm *systemdManager) getServiceStatsCount() int { return len(sm.serviceStatsMap) } // getFailedServiceCount returns the number of systemd services in a failed state. func (sm *systemdManager) getFailedServiceCount() uint16 { sm.Lock() defer sm.Unlock() count := uint16(0) for _, service := range sm.serviceStatsMap { if service.State == systemd.StatusFailed { count++ } } return count } // getServiceStats collects statistics for all running systemd services. func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service { // start := time.Now() // defer func() { // slog.Info("systemdManager.getServiceStats", "duration", time.Since(start)) // }() var services []*systemd.Service var err error if !refresh { // return nil sm.Lock() defer sm.Unlock() for _, service := range sm.serviceStatsMap { services = append(services, service) } sm.hasFreshStats = false return services } if conn == nil || !conn.Connected() { conn, err = dbus.NewSystemConnectionContext(context.Background()) if err != nil { return nil } defer conn.Close() } units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns) if err != nil { slog.Error("Error listing systemd service units", "err", err) return nil } // Track which units are currently present to remove stale entries currentUnits := make(map[string]struct{}, len(units)) for _, unit := range units { currentUnits[unit.Name] = struct{}{} service, err := sm.updateServiceStats(conn, unit) if err != nil { continue } services = append(services, service) } // Remove services that no longer exist in systemd sm.Lock() for unitName := range sm.serviceStatsMap { if _, exists := currentUnits[unitName]; !exists { delete(sm.serviceStatsMap, unitName) } } sm.Unlock() sm.hasFreshStats = true return services } // updateServiceStats updates the statistics for a single systemd service. func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) { sm.Lock() defer sm.Unlock() ctx := context.Background() // if service has never been active (no active since time), skip it if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil { if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 { return nil, errNoActiveTime } } else { return nil, err } service, serviceExists := sm.serviceStatsMap[unit.Name] if !serviceExists { service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))} sm.serviceStatsMap[unit.Name] = service } memPeak := service.MemPeak if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil { // If memPeak is MaxUint64 the api is saying it's not available if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 { memPeak = v } } var memUsage uint64 if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil { // If memUsage is MaxUint64 the api is saying it's not available if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 { memUsage = v } } service.State = systemd.ParseServiceStatus(unit.ActiveState) service.Sub = systemd.ParseServiceSubState(unit.SubState) // some systems always return 0 for mem peak, so we should update the peak if the current usage is greater if memUsage > memPeak { memPeak = memUsage } var cpuUsage uint64 if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil { if v, ok := cpuProp.Value.Value().(uint64); ok { cpuUsage = v } } service.Mem = memUsage if memPeak > service.MemPeak { service.MemPeak = memPeak } service.UpdateCPUPercent(cpuUsage) return service, nil } // getServiceDetails collects extended information for a specific systemd service. func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) { conn, err := dbus.NewSystemConnectionContext(context.Background()) if err != nil { return nil, err } defer conn.Close() unitName := serviceName if !strings.HasSuffix(unitName, ".service") { unitName += ".service" } ctx := context.Background() props, err := conn.GetUnitPropertiesContext(ctx, unitName) if err != nil { return nil, err } // Start with all unit properties details := make(systemd.ServiceDetails) maps.Copy(details, props) // // Add service-specific properties servicePropNames := []string{ "MainPID", "ExecMainPID", "TasksCurrent", "TasksMax", "MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec", "NRestarts", "ExecMainStartTimestampRealtime", "Result", } for _, propName := range servicePropNames { if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil { value := variant.Value.Value() // Check if the value is MaxUint64, which indicates unlimited/infinite if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 { // Set to nil to indicate unlimited - frontend will handle this appropriately details[propName] = nil } else { details[propName] = value } } } return details, nil } // unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d func unescapeServiceName(name string) string { if !strings.Contains(name, "\\x") { return name } unescaped, err := strconv.Unquote("\"" + name + "\"") if err != nil { return name } return unescaped } // getServicePatterns returns the list of service patterns to match. // It reads from the SERVICE_PATTERNS environment variable if set, // otherwise defaults to "*service". func getServicePatterns() []string { patterns := []string{} if envPatterns, _ := utils.GetEnv("SERVICE_PATTERNS"); envPatterns != "" { for pattern := range strings.SplitSeq(envPatterns, ",") { pattern = strings.TrimSpace(pattern) if pattern == "" { continue } if !strings.HasSuffix(pattern, "timer") && !strings.HasSuffix(pattern, ".service") { pattern += ".service" } patterns = append(patterns, pattern) } } if len(patterns) == 0 { patterns = []string{"*.service"} } return patterns } ================================================ FILE: agent/systemd_nonlinux.go ================================================ //go:build !linux package agent import ( "errors" "github.com/henrygd/beszel/internal/entities/systemd" ) // systemdManager manages the collection of systemd service statistics. type systemdManager struct { hasFreshStats bool } // newSystemdManager creates a new systemdManager. func newSystemdManager() (*systemdManager, error) { return &systemdManager{}, nil } // getServiceStats returns nil for non-linux systems. func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service { return nil } // getServiceStatsCount returns 0 for non-linux systems. func (sm *systemdManager) getServiceStatsCount() int { return 0 } // getFailedServiceCount returns 0 for non-linux systems. func (sm *systemdManager) getFailedServiceCount() uint16 { return 0 } func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) { return nil, errors.New("systemd manager unavailable") } ================================================ FILE: agent/systemd_nonlinux_test.go ================================================ //go:build !linux && testing package agent import ( "testing" "github.com/stretchr/testify/assert" ) func TestNewSystemdManager(t *testing.T) { manager, err := newSystemdManager() assert.NoError(t, err) assert.NotNil(t, manager) } func TestSystemdManagerGetServiceStats(t *testing.T) { manager, err := newSystemdManager() assert.NoError(t, err) // Test with refresh = true result := manager.getServiceStats("any-service", true) assert.Nil(t, result) // Test with refresh = false result = manager.getServiceStats("any-service", false) assert.Nil(t, result) } func TestSystemdManagerGetServiceDetails(t *testing.T) { manager, err := newSystemdManager() assert.NoError(t, err) result, err := manager.getServiceDetails("any-service") assert.Error(t, err) assert.Equal(t, "systemd manager unavailable", err.Error()) assert.Nil(t, result) // Test with empty service name result, err = manager.getServiceDetails("") assert.Error(t, err) assert.Equal(t, "systemd manager unavailable", err.Error()) assert.Nil(t, result) } func TestSystemdManagerFields(t *testing.T) { manager, err := newSystemdManager() assert.NoError(t, err) // The non-linux manager should be a simple struct with no special fields // We can't test private fields directly, but we can test the methods work assert.NotNil(t, manager) } ================================================ FILE: agent/systemd_test.go ================================================ //go:build linux && testing package agent import ( "os" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestUnescapeServiceName(t *testing.T) { tests := []struct { input string expected string }{ {"nginx.service", "nginx.service"}, // No escaping needed {"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash {"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space {"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal {"no-escape-here", "no-escape-here"}, // No escape sequences {"", ""}, // Empty string {"\\x2d\\x2d", "--"}, // Multiple escapes } for _, test := range tests { t.Run(test.input, func(t *testing.T) { result := unescapeServiceName(test.input) assert.Equal(t, test.expected, result) }) } } func TestUnescapeServiceNameInvalid(t *testing.T) { // Test invalid escape sequences - should return original string invalidInputs := []string{ "invalid\\x", // Incomplete escape "invalid\\xZZ", // Invalid hex "invalid\\x2", // Incomplete hex "invalid\\xyz", // Not a valid escape } for _, input := range invalidInputs { t.Run(input, func(t *testing.T) { result := unescapeServiceName(input) assert.Equal(t, input, result, "Invalid escape sequences should return original string") }) } } func TestIsSystemdAvailable(t *testing.T) { // Note: This test's result will vary based on the actual system running the tests // On systems with systemd, it should return true // On systems without systemd, it should return false result := isSystemdAvailable() // Check if either the /run/systemd/system directory exists or PID 1 is systemd runSystemdExists := false if _, err := os.Stat("/run/systemd/system"); err == nil { runSystemdExists = true } pid1IsSystemd := false if data, err := os.ReadFile("/proc/1/comm"); err == nil { pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd" } expected := runSystemdExists || pid1IsSystemd assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence") // Log the result for informational purposes if result { t.Log("Systemd is available on this system") } else { t.Log("Systemd is not available on this system") } } func TestGetServicePatterns(t *testing.T) { tests := []struct { name string prefixedEnv string unprefixedEnv string expected []string cleanupEnvVars bool }{ { name: "default when no env var set", prefixedEnv: "", unprefixedEnv: "", expected: []string{"*.service"}, cleanupEnvVars: true, }, { name: "single pattern with prefixed env", prefixedEnv: "nginx", unprefixedEnv: "", expected: []string{"nginx.service"}, cleanupEnvVars: true, }, { name: "single pattern with unprefixed env", prefixedEnv: "", unprefixedEnv: "nginx", expected: []string{"nginx.service"}, cleanupEnvVars: true, }, { name: "prefixed env takes precedence", prefixedEnv: "nginx", unprefixedEnv: "apache", expected: []string{"nginx.service"}, cleanupEnvVars: true, }, { name: "multiple patterns", prefixedEnv: "nginx,apache,postgresql", unprefixedEnv: "", expected: []string{"nginx.service", "apache.service", "postgresql.service"}, cleanupEnvVars: true, }, { name: "patterns with .service suffix", prefixedEnv: "nginx.service,apache.service", unprefixedEnv: "", expected: []string{"nginx.service", "apache.service"}, cleanupEnvVars: true, }, { name: "mixed patterns with and without suffix", prefixedEnv: "nginx.service,apache,postgresql.service", unprefixedEnv: "", expected: []string{"nginx.service", "apache.service", "postgresql.service"}, cleanupEnvVars: true, }, { name: "patterns with whitespace", prefixedEnv: " nginx , apache , postgresql ", unprefixedEnv: "", expected: []string{"nginx.service", "apache.service", "postgresql.service"}, cleanupEnvVars: true, }, { name: "empty patterns are skipped", prefixedEnv: "nginx,,apache, ,postgresql", unprefixedEnv: "", expected: []string{"nginx.service", "apache.service", "postgresql.service"}, cleanupEnvVars: true, }, { name: "wildcard pattern", prefixedEnv: "*nginx*,*apache*", unprefixedEnv: "", expected: []string{"*nginx*.service", "*apache*.service"}, cleanupEnvVars: true, }, { name: "opt into timer monitoring", prefixedEnv: "nginx.service,docker,apache.timer", unprefixedEnv: "", expected: []string{"nginx.service", "docker.service", "apache.timer"}, cleanupEnvVars: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Clean up any existing env vars os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS") os.Unsetenv("SERVICE_PATTERNS") // Set up environment variables if tt.prefixedEnv != "" { os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv) } if tt.unprefixedEnv != "" { os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv) } // Run the function result := getServicePatterns() // Verify results assert.Equal(t, tt.expected, result, "Patterns should match expected values") // Cleanup if tt.cleanupEnvVars { os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS") os.Unsetenv("SERVICE_PATTERNS") } }) } } ================================================ FILE: agent/test-data/amdgpu.ids ================================================ # List of AMDGPU IDs # # Syntax: # device_id, revision_id, product_name <-- single tab after comma 1.0.0 1114, C2, AMD Radeon 860M Graphics 1114, C3, AMD Radeon 840M Graphics 1114, D2, AMD Radeon 860M Graphics 1114, D3, AMD Radeon 840M Graphics 1309, 00, AMD Radeon R7 Graphics 130A, 00, AMD Radeon R6 Graphics 130B, 00, AMD Radeon R4 Graphics 130C, 00, AMD Radeon R7 Graphics 130D, 00, AMD Radeon R6 Graphics 130E, 00, AMD Radeon R5 Graphics 130F, 00, AMD Radeon R7 Graphics 130F, D4, AMD Radeon R7 Graphics 130F, D5, AMD Radeon R7 Graphics 130F, D6, AMD Radeon R7 Graphics 130F, D7, AMD Radeon R7 Graphics 1313, 00, AMD Radeon R7 Graphics 1313, D4, AMD Radeon R7 Graphics 1313, D5, AMD Radeon R7 Graphics 1313, D6, AMD Radeon R7 Graphics 1315, 00, AMD Radeon R5 Graphics 1315, D4, AMD Radeon R5 Graphics 1315, D5, AMD Radeon R5 Graphics 1315, D6, AMD Radeon R5 Graphics 1315, D7, AMD Radeon R5 Graphics 1316, 00, AMD Radeon R5 Graphics 1318, 00, AMD Radeon R5 Graphics 131B, 00, AMD Radeon R4 Graphics 131C, 00, AMD Radeon R7 Graphics 131D, 00, AMD Radeon R6 Graphics 1435, AE, AMD Custom GPU 0932 1506, C1, AMD Radeon 610M 1506, C2, AMD Radeon 610M 1506, C3, AMD Radeon 610M 1506, C4, AMD Radeon 610M 150E, C1, AMD Radeon 890M Graphics 150E, C4, AMD Radeon 890M Graphics 150E, C5, AMD Radeon 890M Graphics 150E, C6, AMD Radeon 890M Graphics 150E, D1, AMD Radeon 890M Graphics 150E, D2, AMD Radeon 890M Graphics 150E, D3, AMD Radeon 890M Graphics 1586, C1, Radeon 8060S Graphics 1586, C2, Radeon 8050S Graphics 1586, C4, Radeon 8050S Graphics 1586, D1, Radeon 8060S Graphics 1586, D2, Radeon 8050S Graphics 1586, D4, Radeon 8050S Graphics 1586, D5, Radeon 8040S Graphics 15BF, 00, AMD Radeon 780M Graphics 15BF, 01, AMD Radeon 760M Graphics 15BF, 02, AMD Radeon 780M Graphics 15BF, 03, AMD Radeon 760M Graphics 15BF, C1, AMD Radeon 780M Graphics 15BF, C2, AMD Radeon 780M Graphics 15BF, C3, AMD Radeon 760M Graphics 15BF, C4, AMD Radeon 780M Graphics 15BF, C5, AMD Radeon 740M Graphics 15BF, C6, AMD Radeon 780M Graphics 15BF, C7, AMD Radeon 780M Graphics 15BF, C8, AMD Radeon 760M Graphics 15BF, C9, AMD Radeon 780M Graphics 15BF, CA, AMD Radeon 740M Graphics 15BF, CB, AMD Radeon 760M Graphics 15BF, CC, AMD Radeon 740M Graphics 15BF, CD, AMD Radeon 760M Graphics 15BF, CF, AMD Radeon 780M Graphics 15BF, D0, AMD Radeon 780M Graphics 15BF, D1, AMD Radeon 780M Graphics 15BF, D2, AMD Radeon 780M Graphics 15BF, D3, AMD Radeon 780M Graphics 15BF, D4, AMD Radeon 780M Graphics 15BF, D5, AMD Radeon 760M Graphics 15BF, D6, AMD Radeon 760M Graphics 15BF, D7, AMD Radeon 780M Graphics 15BF, D8, AMD Radeon 740M Graphics 15BF, D9, AMD Radeon 780M Graphics 15BF, DA, AMD Radeon 780M Graphics 15BF, DB, AMD Radeon 760M Graphics 15BF, DC, AMD Radeon 760M Graphics 15BF, DD, AMD Radeon 780M Graphics 15BF, DE, AMD Radeon 740M Graphics 15BF, DF, AMD Radeon 760M Graphics 15BF, F0, AMD Radeon 760M Graphics 15C8, C1, AMD Radeon 740M Graphics 15C8, C2, AMD Radeon 740M Graphics 15C8, C3, AMD Radeon 740M Graphics 15C8, C4, AMD Radeon 740M Graphics 15C8, D1, AMD Radeon 740M Graphics 15C8, D2, AMD Radeon 740M Graphics 15C8, D3, AMD Radeon 740M Graphics 15C8, D4, AMD Radeon 740M Graphics 15D8, 00, AMD Radeon RX Vega 8 Graphics WS 15D8, 91, AMD Radeon Vega 3 Graphics 15D8, 91, AMD Ryzen Embedded R1606G with Radeon Vega Gfx 15D8, 92, AMD Radeon Vega 3 Graphics 15D8, 92, AMD Ryzen Embedded R1505G with Radeon Vega Gfx 15D8, 93, AMD Radeon Vega 1 Graphics 15D8, A1, AMD Radeon Vega 10 Graphics 15D8, A2, AMD Radeon Vega 8 Graphics 15D8, A3, AMD Radeon Vega 6 Graphics 15D8, A4, AMD Radeon Vega 3 Graphics 15D8, B1, AMD Radeon Vega 10 Graphics 15D8, B2, AMD Radeon Vega 8 Graphics 15D8, B3, AMD Radeon Vega 6 Graphics 15D8, B4, AMD Radeon Vega 3 Graphics 15D8, C1, AMD Radeon Vega 10 Graphics 15D8, C2, AMD Radeon Vega 8 Graphics 15D8, C3, AMD Radeon Vega 6 Graphics 15D8, C4, AMD Radeon Vega 3 Graphics 15D8, C5, AMD Radeon Vega 3 Graphics 15D8, C8, AMD Radeon Vega 11 Graphics 15D8, C9, AMD Radeon Vega 8 Graphics 15D8, CA, AMD Radeon Vega 11 Graphics 15D8, CB, AMD Radeon Vega 8 Graphics 15D8, CC, AMD Radeon Vega 3 Graphics 15D8, CE, AMD Radeon Vega 3 Graphics 15D8, CF, AMD Ryzen Embedded R1305G with Radeon Vega Gfx 15D8, D1, AMD Radeon Vega 10 Graphics 15D8, D2, AMD Radeon Vega 8 Graphics 15D8, D3, AMD Radeon Vega 6 Graphics 15D8, D4, AMD Radeon Vega 3 Graphics 15D8, D8, AMD Radeon Vega 11 Graphics 15D8, D9, AMD Radeon Vega 8 Graphics 15D8, DA, AMD Radeon Vega 11 Graphics 15D8, DB, AMD Radeon Vega 3 Graphics 15D8, DB, AMD Radeon Vega 8 Graphics 15D8, DC, AMD Radeon Vega 3 Graphics 15D8, DD, AMD Radeon Vega 3 Graphics 15D8, DE, AMD Radeon Vega 3 Graphics 15D8, DF, AMD Radeon Vega 3 Graphics 15D8, E3, AMD Radeon Vega 3 Graphics 15D8, E4, AMD Ryzen Embedded R1102G with Radeon Vega Gfx 15DD, 81, AMD Ryzen Embedded V1807B with Radeon Vega Gfx 15DD, 82, AMD Ryzen Embedded V1756B with Radeon Vega Gfx 15DD, 83, AMD Ryzen Embedded V1605B with Radeon Vega Gfx 15DD, 84, AMD Radeon Vega 6 Graphics 15DD, 85, AMD Ryzen Embedded V1202B with Radeon Vega Gfx 15DD, 86, AMD Radeon Vega 11 Graphics 15DD, 88, AMD Radeon Vega 8 Graphics 15DD, C1, AMD Radeon Vega 11 Graphics 15DD, C2, AMD Radeon Vega 8 Graphics 15DD, C3, AMD Radeon Vega 3 / 10 Graphics 15DD, C4, AMD Radeon Vega 8 Graphics 15DD, C5, AMD Radeon Vega 3 Graphics 15DD, C6, AMD Radeon Vega 11 Graphics 15DD, C8, AMD Radeon Vega 8 Graphics 15DD, C9, AMD Radeon Vega 11 Graphics 15DD, CA, AMD Radeon Vega 8 Graphics 15DD, CB, AMD Radeon Vega 3 Graphics 15DD, CC, AMD Radeon Vega 6 Graphics 15DD, CE, AMD Radeon Vega 3 Graphics 15DD, CF, AMD Radeon Vega 3 Graphics 15DD, D0, AMD Radeon Vega 10 Graphics 15DD, D1, AMD Radeon Vega 8 Graphics 15DD, D3, AMD Radeon Vega 11 Graphics 15DD, D5, AMD Radeon Vega 8 Graphics 15DD, D6, AMD Radeon Vega 11 Graphics 15DD, D7, AMD Radeon Vega 8 Graphics 15DD, D8, AMD Radeon Vega 3 Graphics 15DD, D9, AMD Radeon Vega 6 Graphics 15DD, E1, AMD Radeon Vega 3 Graphics 15DD, E2, AMD Radeon Vega 3 Graphics 163F, AE, AMD Custom GPU 0405 163F, E1, AMD Custom GPU 0405 164E, D8, AMD Radeon 610M 164E, D9, AMD Radeon 610M 164E, DA, AMD Radeon 610M 164E, DB, AMD Radeon 610M 164E, DC, AMD Radeon 610M 1681, 06, AMD Radeon 680M 1681, 07, AMD Radeon 660M 1681, 0A, AMD Radeon 680M 1681, 0B, AMD Radeon 660M 1681, C7, AMD Radeon 680M 1681, C8, AMD Radeon 680M 1681, C9, AMD Radeon 660M 1900, 01, AMD Radeon 780M Graphics 1900, 02, AMD Radeon 760M Graphics 1900, 03, AMD Radeon 780M Graphics 1900, 04, AMD Radeon 760M Graphics 1900, 05, AMD Radeon 780M Graphics 1900, 06, AMD Radeon 780M Graphics 1900, 07, AMD Radeon 760M Graphics 1900, B0, AMD Radeon 780M Graphics 1900, B1, AMD Radeon 780M Graphics 1900, B2, AMD Radeon 780M Graphics 1900, B3, AMD Radeon 780M Graphics 1900, B4, AMD Radeon 780M Graphics 1900, B5, AMD Radeon 780M Graphics 1900, B6, AMD Radeon 780M Graphics 1900, B7, AMD Radeon 760M Graphics 1900, B8, AMD Radeon 760M Graphics 1900, B9, AMD Radeon 780M Graphics 1900, BA, AMD Radeon 780M Graphics 1900, BB, AMD Radeon 780M Graphics 1900, C0, AMD Radeon 780M Graphics 1900, C1, AMD Radeon 760M Graphics 1900, C2, AMD Radeon 780M Graphics 1900, C3, AMD Radeon 760M Graphics 1900, C4, AMD Radeon 780M Graphics 1900, C5, AMD Radeon 780M Graphics 1900, C6, AMD Radeon 760M Graphics 1900, C7, AMD Radeon 780M Graphics 1900, C8, AMD Radeon 760M Graphics 1900, C9, AMD Radeon 780M Graphics 1900, CA, AMD Radeon 760M Graphics 1900, CB, AMD Radeon 780M Graphics 1900, CC, AMD Radeon 780M Graphics 1900, CD, AMD Radeon 760M Graphics 1900, CE, AMD Radeon 780M Graphics 1900, CF, AMD Radeon 760M Graphics 1900, D0, AMD Radeon 780M Graphics 1900, D1, AMD Radeon 760M Graphics 1900, D2, AMD Radeon 780M Graphics 1900, D3, AMD Radeon 760M Graphics 1900, D4, AMD Radeon 780M Graphics 1900, D5, AMD Radeon 780M Graphics 1900, D6, AMD Radeon 760M Graphics 1900, D7, AMD Radeon 780M Graphics 1900, D8, AMD Radeon 760M Graphics 1900, D9, AMD Radeon 780M Graphics 1900, DA, AMD Radeon 760M Graphics 1900, DB, AMD Radeon 780M Graphics 1900, DC, AMD Radeon 780M Graphics 1900, DD, AMD Radeon 760M Graphics 1900, DE, AMD Radeon 780M Graphics 1900, DF, AMD Radeon 760M Graphics 1900, F0, AMD Radeon 780M Graphics 1900, F1, AMD Radeon 780M Graphics 1900, F2, AMD Radeon 780M Graphics 1901, C1, AMD Radeon 740M Graphics 1901, C2, AMD Radeon 740M Graphics 1901, C3, AMD Radeon 740M Graphics 1901, C6, AMD Radeon 740M Graphics 1901, C7, AMD Radeon 740M Graphics 1901, C8, AMD Radeon 740M Graphics 1901, C9, AMD Radeon 740M Graphics 1901, CA, AMD Radeon 740M Graphics 1901, D1, AMD Radeon 740M Graphics 1901, D2, AMD Radeon 740M Graphics 1901, D3, AMD Radeon 740M Graphics 1901, D4, AMD Radeon 740M Graphics 1901, D5, AMD Radeon 740M Graphics 1901, D6, AMD Radeon 740M Graphics 1901, D7, AMD Radeon 740M Graphics 1901, D8, AMD Radeon 740M Graphics 6600, 00, AMD Radeon HD 8600 / 8700M 6600, 81, AMD Radeon R7 M370 6601, 00, AMD Radeon HD 8500M / 8700M 6604, 00, AMD Radeon R7 M265 Series 6604, 81, AMD Radeon R7 M350 6605, 00, AMD Radeon R7 M260 Series 6605, 81, AMD Radeon R7 M340 6606, 00, AMD Radeon HD 8790M 6607, 00, AMD Radeon R5 M240 6608, 00, AMD FirePro W2100 6610, 00, AMD Radeon R7 200 Series 6610, 81, AMD Radeon R7 350 6610, 83, AMD Radeon R5 340 6610, 87, AMD Radeon R7 200 Series 6611, 00, AMD Radeon R7 200 Series 6611, 87, AMD Radeon R7 200 Series 6613, 00, AMD Radeon R7 200 Series 6617, 00, AMD Radeon R7 240 Series 6617, 87, AMD Radeon R7 200 Series 6617, C7, AMD Radeon R7 240 Series 6640, 00, AMD Radeon HD 8950 6640, 80, AMD Radeon R9 M380 6646, 00, AMD Radeon R9 M280X 6646, 80, AMD Radeon R9 M385 6646, 80, AMD Radeon R9 M470X 6647, 00, AMD Radeon R9 M200X Series 6647, 80, AMD Radeon R9 M380 6649, 00, AMD FirePro W5100 6658, 00, AMD Radeon R7 200 Series 665C, 00, AMD Radeon HD 7700 Series 665D, 00, AMD Radeon R7 200 Series 665F, 81, AMD Radeon R7 360 Series 6660, 00, AMD Radeon HD 8600M Series 6660, 81, AMD Radeon R5 M335 6660, 83, AMD Radeon R5 M330 6663, 00, AMD Radeon HD 8500M Series 6663, 83, AMD Radeon R5 M320 6664, 00, AMD Radeon R5 M200 Series 6665, 00, AMD Radeon R5 M230 Series 6665, 83, AMD Radeon R5 M320 6665, C3, AMD Radeon R5 M435 6666, 00, AMD Radeon R5 M200 Series 6667, 00, AMD Radeon R5 M200 Series 666F, 00, AMD Radeon HD 8500M 66A1, 02, AMD Instinct MI60 / MI50 66A1, 06, AMD Radeon Pro VII 66AF, C1, AMD Radeon VII 6780, 00, AMD FirePro W9000 6784, 00, ATI FirePro V (FireGL V) Graphics Adapter 6788, 00, ATI FirePro V (FireGL V) Graphics Adapter 678A, 00, AMD FirePro W8000 6798, 00, AMD Radeon R9 200 / HD 7900 Series 6799, 00, AMD Radeon HD 7900 Series 679A, 00, AMD Radeon HD 7900 Series 679B, 00, AMD Radeon HD 7900 Series 679E, 00, AMD Radeon HD 7800 Series 67A0, 00, AMD Radeon FirePro W9100 67A1, 00, AMD Radeon FirePro W8100 67B0, 00, AMD Radeon R9 200 Series 67B0, 80, AMD Radeon R9 390 Series 67B1, 00, AMD Radeon R9 200 Series 67B1, 80, AMD Radeon R9 390 Series 67B9, 00, AMD Radeon R9 200 Series 67C0, 00, AMD Radeon Pro WX 7100 Graphics 67C0, 80, AMD Radeon E9550 67C2, 01, AMD Radeon Pro V7350x2 67C2, 02, AMD Radeon Pro V7300X 67C4, 00, AMD Radeon Pro WX 7100 Graphics 67C4, 80, AMD Radeon E9560 / E9565 Graphics 67C7, 00, AMD Radeon Pro WX 5100 Graphics 67C7, 80, AMD Radeon E9390 Graphics 67D0, 01, AMD Radeon Pro V7350x2 67D0, 02, AMD Radeon Pro V7300X 67DF, C0, AMD Radeon Pro 580X 67DF, C1, AMD Radeon RX 580 Series 67DF, C2, AMD Radeon RX 570 Series 67DF, C3, AMD Radeon RX 580 Series 67DF, C4, AMD Radeon RX 480 Graphics 67DF, C5, AMD Radeon RX 470 Graphics 67DF, C6, AMD Radeon RX 570 Series 67DF, C7, AMD Radeon RX 480 Graphics 67DF, CF, AMD Radeon RX 470 Graphics 67DF, D7, AMD Radeon RX 470 Graphics 67DF, E0, AMD Radeon RX 470 Series 67DF, E1, AMD Radeon RX 590 Series 67DF, E3, AMD Radeon RX Series 67DF, E7, AMD Radeon RX 580 Series 67DF, EB, AMD Radeon Pro 580X 67DF, EF, AMD Radeon RX 570 Series 67DF, F7, AMD Radeon RX P30PH 67DF, FF, AMD Radeon RX 470 Series 67E0, 00, AMD Radeon Pro WX Series 67E3, 00, AMD Radeon Pro WX 4100 67E8, 00, AMD Radeon Pro WX Series 67E8, 01, AMD Radeon Pro WX Series 67E8, 80, AMD Radeon E9260 Graphics 67EB, 00, AMD Radeon Pro V5300X 67EF, C0, AMD Radeon RX Graphics 67EF, C1, AMD Radeon RX 460 Graphics 67EF, C2, AMD Radeon Pro Series 67EF, C3, AMD Radeon RX Series 67EF, C5, AMD Radeon RX 460 Graphics 67EF, C7, AMD Radeon RX Graphics 67EF, CF, AMD Radeon RX 460 Graphics 67EF, E0, AMD Radeon RX 560 Series 67EF, E1, AMD Radeon RX Series 67EF, E2, AMD Radeon RX 560X 67EF, E3, AMD Radeon RX Series 67EF, E5, AMD Radeon RX 560 Series 67EF, E7, AMD Radeon RX 560 Series 67EF, EF, AMD Radeon 550 Series 67EF, FF, AMD Radeon RX 460 Graphics 67FF, C0, AMD Radeon Pro 465 67FF, C1, AMD Radeon RX 560 Series 67FF, CF, AMD Radeon RX 560 Series 67FF, EF, AMD Radeon RX 560 Series 67FF, FF, AMD Radeon RX 550 Series 6800, 00, AMD Radeon HD 7970M 6801, 00, AMD Radeon HD 8970M 6806, 00, AMD Radeon R9 M290X 6808, 00, AMD FirePro W7000 6808, 00, ATI FirePro V (FireGL V) Graphics Adapter 6809, 00, ATI FirePro W5000 6810, 00, AMD Radeon R9 200 Series 6810, 81, AMD Radeon R9 370 Series 6811, 00, AMD Radeon R9 200 Series 6811, 81, AMD Radeon R7 370 Series 6818, 00, AMD Radeon HD 7800 Series 6819, 00, AMD Radeon HD 7800 Series 6820, 00, AMD Radeon R9 M275X 6820, 81, AMD Radeon R9 M375 6820, 83, AMD Radeon R9 M375X 6821, 00, AMD Radeon R9 M200X Series 6821, 83, AMD Radeon R9 M370X 6821, 87, AMD Radeon R7 M380 6822, 00, AMD Radeon E8860 6823, 00, AMD Radeon R9 M200X Series 6825, 00, AMD Radeon HD 7800M Series 6826, 00, AMD Radeon HD 7700M Series 6827, 00, AMD Radeon HD 7800M Series 6828, 00, AMD FirePro W600 682B, 00, AMD Radeon HD 8800M Series 682B, 87, AMD Radeon R9 M360 682C, 00, AMD FirePro W4100 682D, 00, AMD Radeon HD 7700M Series 682F, 00, AMD Radeon HD 7700M Series 6830, 00, AMD Radeon 7800M Series 6831, 00, AMD Radeon 7700M Series 6835, 00, AMD Radeon R7 Series / HD 9000 Series 6837, 00, AMD Radeon HD 7700 Series 683D, 00, AMD Radeon HD 7700 Series 683F, 00, AMD Radeon HD 7700 Series 684C, 00, ATI FirePro V (FireGL V) Graphics Adapter 6860, 00, AMD Radeon Instinct MI25 6860, 01, AMD Radeon Instinct MI25 6860, 02, AMD Radeon Instinct MI25 6860, 03, AMD Radeon Pro V340 6860, 04, AMD Radeon Instinct MI25x2 6860, 07, AMD Radeon Pro V320 6861, 00, AMD Radeon Pro WX 9100 6862, 00, AMD Radeon Pro SSG 6863, 00, AMD Radeon Vega Frontier Edition 6864, 03, AMD Radeon Pro V340 6864, 04, AMD Radeon Instinct MI25x2 6864, 05, AMD Radeon Pro V340 6868, 00, AMD Radeon Pro WX 8200 686C, 00, AMD Radeon Instinct MI25 MxGPU 686C, 01, AMD Radeon Instinct MI25 MxGPU 686C, 02, AMD Radeon Instinct MI25 MxGPU 686C, 03, AMD Radeon Pro V340 MxGPU 686C, 04, AMD Radeon Instinct MI25x2 MxGPU 686C, 05, AMD Radeon Pro V340L MxGPU 686C, 06, AMD Radeon Instinct MI25 MxGPU 687F, 01, AMD Radeon RX Vega 687F, C0, AMD Radeon RX Vega 687F, C1, AMD Radeon RX Vega 687F, C3, AMD Radeon RX Vega 687F, C7, AMD Radeon RX Vega 6900, 00, AMD Radeon R7 M260 6900, 81, AMD Radeon R7 M360 6900, 83, AMD Radeon R7 M340 6900, C1, AMD Radeon R5 M465 Series 6900, C3, AMD Radeon R5 M445 Series 6900, D1, AMD Radeon 530 Series 6900, D3, AMD Radeon 530 Series 6901, 00, AMD Radeon R5 M255 6902, 00, AMD Radeon Series 6907, 00, AMD Radeon R5 M255 6907, 87, AMD Radeon R5 M315 6920, 00, AMD Radeon R9 M395X 6920, 01, AMD Radeon R9 M390X 6921, 00, AMD Radeon R9 M390X 6929, 00, AMD FirePro S7150 6929, 01, AMD FirePro S7100X 692B, 00, AMD FirePro W7100 6938, 00, AMD Radeon R9 200 Series 6938, F0, AMD Radeon R9 200 Series 6938, F1, AMD Radeon R9 380 Series 6939, 00, AMD Radeon R9 200 Series 6939, F0, AMD Radeon R9 200 Series 6939, F1, AMD Radeon R9 380 Series 694C, C0, AMD Radeon RX Vega M GH Graphics 694E, C0, AMD Radeon RX Vega M GL Graphics 6980, 00, AMD Radeon Pro WX 3100 6981, 00, AMD Radeon Pro WX 3200 Series 6981, 01, AMD Radeon Pro WX 3200 Series 6981, 10, AMD Radeon Pro WX 3200 Series 6985, 00, AMD Radeon Pro WX 3100 6986, 00, AMD Radeon Pro WX 2100 6987, 80, AMD Embedded Radeon E9171 6987, C0, AMD Radeon 550X Series 6987, C1, AMD Radeon RX 640 6987, C3, AMD Radeon 540X Series 6987, C7, AMD Radeon 540 6995, 00, AMD Radeon Pro WX 2100 6997, 00, AMD Radeon Pro WX 2100 699F, 81, AMD Embedded Radeon E9170 Series 699F, C0, AMD Radeon 500 Series 699F, C1, AMD Radeon 540 Series 699F, C3, AMD Radeon 500 Series 699F, C7, AMD Radeon RX 550 / 550 Series 699F, C9, AMD Radeon 540 6FDF, E7, AMD Radeon RX 590 GME 6FDF, EF, AMD Radeon RX 580 2048SP 7300, C1, AMD FirePro S9300 x2 7300, C8, AMD Radeon R9 Fury Series 7300, C9, AMD Radeon Pro Duo 7300, CA, AMD Radeon R9 Fury Series 7300, CB, AMD Radeon R9 Fury Series 7312, 00, AMD Radeon Pro W5700 731E, C6, AMD Radeon RX 5700XTB 731E, C7, AMD Radeon RX 5700B 731F, C0, AMD Radeon RX 5700 XT 50th Anniversary 731F, C1, AMD Radeon RX 5700 XT 731F, C2, AMD Radeon RX 5600M 731F, C3, AMD Radeon RX 5700M 731F, C4, AMD Radeon RX 5700 731F, C5, AMD Radeon RX 5700 XT 731F, CA, AMD Radeon RX 5600 XT 731F, CB, AMD Radeon RX 5600 OEM 7340, C1, AMD Radeon RX 5500M 7340, C3, AMD Radeon RX 5300M 7340, C5, AMD Radeon RX 5500 XT 7340, C7, AMD Radeon RX 5500 7340, C9, AMD Radeon RX 5500XTB 7340, CF, AMD Radeon RX 5300 7341, 00, AMD Radeon Pro W5500 7347, 00, AMD Radeon Pro W5500M 7360, 41, AMD Radeon Pro 5600M 7360, C3, AMD Radeon Pro V520 7362, C1, AMD Radeon Pro V540 7362, C3, AMD Radeon Pro V520 738C, 01, AMD Instinct MI100 73A1, 00, AMD Radeon Pro V620 73A3, 00, AMD Radeon Pro W6800 73A5, C0, AMD Radeon RX 6950 XT 73AE, 00, AMD Radeon Pro V620 MxGPU 73AF, C0, AMD Radeon RX 6900 XT 73BF, C0, AMD Radeon RX 6900 XT 73BF, C1, AMD Radeon RX 6800 XT 73BF, C3, AMD Radeon RX 6800 73DF, C0, AMD Radeon RX 6750 XT 73DF, C1, AMD Radeon RX 6700 XT 73DF, C2, AMD Radeon RX 6800M 73DF, C3, AMD Radeon RX 6800M 73DF, C5, AMD Radeon RX 6700 XT 73DF, CF, AMD Radeon RX 6700M 73DF, D5, AMD Radeon RX 6750 GRE 12GB 73DF, D7, AMD TDC-235 73DF, DF, AMD Radeon RX 6700 73DF, E5, AMD Radeon RX 6750 GRE 12GB 73DF, FF, AMD Radeon RX 6700 73E0, 00, AMD Radeon RX 6600M 73E1, 00, AMD Radeon Pro W6600M 73E3, 00, AMD Radeon Pro W6600 73EF, C0, AMD Radeon RX 6800S 73EF, C1, AMD Radeon RX 6650 XT 73EF, C2, AMD Radeon RX 6700S 73EF, C3, AMD Radeon RX 6650M 73EF, C4, AMD Radeon RX 6650M XT 73FF, C1, AMD Radeon RX 6600 XT 73FF, C3, AMD Radeon RX 6600M 73FF, C7, AMD Radeon RX 6600 73FF, CB, AMD Radeon RX 6600S 73FF, CF, AMD Radeon RX 6600 LE 73FF, DF, AMD Radeon RX 6750 GRE 10GB 7408, 00, AMD Instinct MI250X 740C, 01, AMD Instinct MI250X / MI250 740F, 02, AMD Instinct MI210 7421, 00, AMD Radeon Pro W6500M 7422, 00, AMD Radeon Pro W6400 7423, 00, AMD Radeon Pro W6300M 7423, 01, AMD Radeon Pro W6300 7424, 00, AMD Radeon RX 6300 743F, C1, AMD Radeon RX 6500 XT 743F, C3, AMD Radeon RX 6500 743F, C3, AMD Radeon RX 6500M 743F, C7, AMD Radeon RX 6400 743F, C8, AMD Radeon RX 6500M 743F, CC, AMD Radeon 6550S 743F, CE, AMD Radeon RX 6450M 743F, CF, AMD Radeon RX 6300M 743F, D3, AMD Radeon RX 6550M 743F, D7, AMD Radeon RX 6400 7448, 00, AMD Radeon Pro W7900 7449, 00, AMD Radeon Pro W7800 48GB 744A, 00, AMD Radeon Pro W7900 Dual Slot 744B, 00, AMD Radeon Pro W7900D 744C, C8, AMD Radeon RX 7900 XTX 744C, CC, AMD Radeon RX 7900 XT 744C, CE, AMD Radeon RX 7900 GRE 744C, CF, AMD Radeon RX 7900M 745E, CC, AMD Radeon Pro W7800 7460, 00, AMD Radeon Pro V710 7461, 00, AMD Radeon Pro V710 MxGPU 7470, 00, AMD Radeon Pro W7700 747E, C8, AMD Radeon RX 7800 XT 747E, D8, AMD Radeon RX 7800M 747E, DB, AMD Radeon RX 7700 747E, FF, AMD Radeon RX 7700 XT 7480, 00, AMD Radeon Pro W7600 7480, C0, AMD Radeon RX 7600 XT 7480, C1, AMD Radeon RX 7700S 7480, C2, AMD Radeon RX 7650 GRE 7480, C3, AMD Radeon RX 7600S 7480, C7, AMD Radeon RX 7600M XT 7480, CF, AMD Radeon RX 7600 7481, C7, AMD Steam Machine 7483, CF, AMD Radeon RX 7600M 7489, 00, AMD Radeon Pro W7500 7499, 00, AMD Radeon Pro W7400 7499, C0, AMD Radeon RX 7400 7499, C1, AMD Radeon RX 7300 74A0, 00, AMD Instinct MI300A 74A1, 00, AMD Instinct MI300X 74A2, 00, AMD Instinct MI308X 74A5, 00, AMD Instinct MI325X 74A8, 00, AMD Instinct MI308X HF 74A9, 00, AMD Instinct MI300X HF 74B5, 00, AMD Instinct MI300X VF 74B6, 00, AMD Instinct MI308X 74BD, 00, AMD Instinct MI300X HF 7550, C0, AMD Radeon RX 9070 XT 7550, C2, AMD Radeon RX 9070 GRE 7550, C3, AMD Radeon RX 9070 7551, C0, AMD Radeon AI PRO R9700 7590, C0, AMD Radeon RX 9060 XT 7590, C7, AMD Radeon RX 9060 75A0, C0, AMD Instinct MI350X 75A3, C0, AMD Instinct MI355X 75B0, C0, AMD Instinct MI350X VF 75B3, C0, AMD Instinct MI355X VF 9830, 00, AMD Radeon HD 8400 / R3 Series 9831, 00, AMD Radeon HD 8400E 9832, 00, AMD Radeon HD 8330 9833, 00, AMD Radeon HD 8330E 9834, 00, AMD Radeon HD 8210 9835, 00, AMD Radeon HD 8210E 9836, 00, AMD Radeon HD 8200 / R3 Series 9837, 00, AMD Radeon HD 8280E 9838, 00, AMD Radeon HD 8200 / R3 series 9839, 00, AMD Radeon HD 8180 983D, 00, AMD Radeon HD 8250 9850, 00, AMD Radeon R3 Graphics 9850, 03, AMD Radeon R3 Graphics 9850, 40, AMD Radeon R2 Graphics 9850, 45, AMD Radeon R3 Graphics 9851, 00, AMD Radeon R4 Graphics 9851, 01, AMD Radeon R5E Graphics 9851, 05, AMD Radeon R5 Graphics 9851, 06, AMD Radeon R5E Graphics 9851, 40, AMD Radeon R4 Graphics 9851, 45, AMD Radeon R5 Graphics 9852, 00, AMD Radeon R2 Graphics 9852, 40, AMD Radeon E1 Graphics 9853, 00, AMD Radeon R2 Graphics 9853, 01, AMD Radeon R4E Graphics 9853, 03, AMD Radeon R2 Graphics 9853, 05, AMD Radeon R1E Graphics 9853, 06, AMD Radeon R1E Graphics 9853, 07, AMD Radeon R1E Graphics 9853, 08, AMD Radeon R1E Graphics 9853, 40, AMD Radeon R2 Graphics 9854, 00, AMD Radeon R3 Graphics 9854, 01, AMD Radeon R3E Graphics 9854, 02, AMD Radeon R3 Graphics 9854, 05, AMD Radeon R2 Graphics 9854, 06, AMD Radeon R4 Graphics 9854, 07, AMD Radeon R3 Graphics 9855, 02, AMD Radeon R6 Graphics 9855, 05, AMD Radeon R4 Graphics 9856, 00, AMD Radeon R2 Graphics 9856, 01, AMD Radeon R2E Graphics 9856, 02, AMD Radeon R2 Graphics 9856, 05, AMD Radeon R1E Graphics 9856, 06, AMD Radeon R2 Graphics 9856, 07, AMD Radeon R1E Graphics 9856, 08, AMD Radeon R1E Graphics 9856, 13, AMD Radeon R1E Graphics 9874, 81, AMD Radeon R6 Graphics 9874, 84, AMD Radeon R7 Graphics 9874, 85, AMD Radeon R6 Graphics 9874, 87, AMD Radeon R5 Graphics 9874, 88, AMD Radeon R7E Graphics 9874, 89, AMD Radeon R6E Graphics 9874, C4, AMD Radeon R7 Graphics 9874, C5, AMD Radeon R6 Graphics 9874, C6, AMD Radeon R6 Graphics 9874, C7, AMD Radeon R5 Graphics 9874, C8, AMD Radeon R7 Graphics 9874, C9, AMD Radeon R7 Graphics 9874, CA, AMD Radeon R5 Graphics 9874, CB, AMD Radeon R5 Graphics 9874, CC, AMD Radeon R7 Graphics 9874, CD, AMD Radeon R7 Graphics 9874, CE, AMD Radeon R5 Graphics 9874, E1, AMD Radeon R7 Graphics 9874, E2, AMD Radeon R7 Graphics 9874, E3, AMD Radeon R7 Graphics 9874, E4, AMD Radeon R7 Graphics 9874, E5, AMD Radeon R5 Graphics 9874, E6, AMD Radeon R5 Graphics 98E4, 80, AMD Radeon R5E Graphics 98E4, 81, AMD Radeon R4E Graphics 98E4, 83, AMD Radeon R2E Graphics 98E4, 84, AMD Radeon R2E Graphics 98E4, 86, AMD Radeon R1E Graphics 98E4, C0, AMD Radeon R4 Graphics 98E4, C1, AMD Radeon R5 Graphics 98E4, C2, AMD Radeon R4 Graphics 98E4, C4, AMD Radeon R5 Graphics 98E4, C6, AMD Radeon R5 Graphics 98E4, C8, AMD Radeon R4 Graphics 98E4, C9, AMD Radeon R4 Graphics 98E4, CA, AMD Radeon R5 Graphics 98E4, D0, AMD Radeon R2 Graphics 98E4, D1, AMD Radeon R2 Graphics 98E4, D2, AMD Radeon R2 Graphics 98E4, D4, AMD Radeon R2 Graphics 98E4, D9, AMD Radeon R5 Graphics 98E4, DA, AMD Radeon R5 Graphics 98E4, DB, AMD Radeon R3 Graphics 98E4, E1, AMD Radeon R3 Graphics 98E4, E2, AMD Radeon R3 Graphics 98E4, E9, AMD Radeon R4 Graphics 98E4, EA, AMD Radeon R4 Graphics 98E4, EB, AMD Radeon R3 Graphics 98E4, EB, AMD Radeon R4 Graphics ================================================ FILE: agent/test-data/container.json ================================================ { "cpu_stats": { "cpu_usage": { "total_usage": 312055276000 }, "system_cpu_usage": 1366399830000000 }, "memory_stats": { "usage": 507400192, "stats": { "inactive_file": 165130240 } }, "networks": { "eth0": { "tx_bytes": 20376558, "rx_bytes": 537029455 }, "eth1": { "tx_bytes": 2003766, "rx_bytes": 6241 } } } ================================================ FILE: agent/test-data/container2.json ================================================ { "cpu_stats": { "cpu_usage": { "total_usage": 314891801000 }, "system_cpu_usage": 1368474900000000 }, "memory_stats": { "usage": 507400192, "stats": { "inactive_file": 165130240 } }, "networks": { "eth0": { "tx_bytes": 20376558, "rx_bytes": 537029455 }, "eth1": { "tx_bytes": 2003766, "rx_bytes": 6241 } } } ================================================ FILE: agent/test-data/nvtop.json ================================================ [ { "device_name": "NVIDIA GeForce RTX 3050 Ti Laptop GPU", "gpu_clock": "1485MHz", "mem_clock": "6001MHz", "temp": "48C", "fan_speed": null, "power_draw": "13W", "gpu_util": "5%", "encode": "0%", "decode": "0%", "mem_util": "8%", "mem_total": "4294967296", "mem_used": "349372416", "mem_free": "3945594880", "processes" : [] }, { "device_name": "AMD Radeon 680M", "gpu_clock": "2200MHz", "mem_clock": "2400MHz", "temp": "48C", "fan_speed": "CPU Fan", "power_draw": "9W", "gpu_util": "12%", "encode": null, "decode": "0%", "mem_util": "7%", "mem_total": "16929173504", "mem_used": "1213784064", "mem_free": "15715389440", "processes" : [] } ] ================================================ FILE: agent/test-data/smart/nvme0.json ================================================ { "json_format_version": [ 1, 0 ], "smartctl": { "version": [ 7, 5 ], "pre_release": false, "svn_revision": "5714", "platform_info": "x86_64-linux-6.17.1-2-cachyos", "build_info": "(local build)", "argv": [ "smartctl", "-aj", "/dev/nvme0" ], "exit_status": 0 }, "local_time": { "time_t": 1761507494, "asctime": "Sun Oct 26 15:38:14 2025 EDT" }, "device": { "name": "/dev/nvme0", "info_name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe" }, "model_name": "PELADN 512GB", "serial_number": "2024031600129", "firmware_version": "VC2S038E", "nvme_pci_vendor": { "id": 4332, "subsystem_id": 4332 }, "nvme_ieee_oui_identifier": 57420, "nvme_controller_id": 1, "nvme_version": { "string": "1.4", "value": 66560 }, "nvme_number_of_namespaces": 1, "nvme_namespaces": [ { "id": 1, "size": { "blocks": 1000215216, "bytes": 512110190592 }, "capacity": { "blocks": 1000215216, "bytes": 512110190592 }, "utilization": { "blocks": 1000215216, "bytes": 512110190592 }, "formatted_lba_size": 512, "eui64": { "oui": 57420, "ext_id": 112094110470 }, "features": { "value": 0, "thin_provisioning": false, "na_fields": false, "dealloc_or_unwritten_block_error": false, "uid_reuse": false, "np_fields": false, "other": 0 }, "lba_formats": [ { "formatted": true, "data_bytes": 512, "metadata_bytes": 0, "relative_performance": 0 } ] } ], "user_capacity": { "blocks": 1000215216, "bytes": 512110190592 }, "logical_block_size": 512, "smart_support": { "available": true, "enabled": true }, "nvme_firmware_update_capabilities": { "value": 2, "slots": 1, "first_slot_is_read_only": false, "activiation_without_reset": false, "multiple_update_detection": false, "other": 0 }, "nvme_optional_admin_commands": { "value": 23, "security_send_receive": true, "format_nvm": true, "firmware_download": true, "namespace_management": false, "self_test": true, "directives": false, "mi_send_receive": false, "virtualization_management": false, "doorbell_buffer_config": false, "get_lba_status": false, "command_and_feature_lockdown": false, "other": 0 }, "nvme_optional_nvm_commands": { "value": 94, "compare": false, "write_uncorrectable": true, "dataset_management": true, "write_zeroes": true, "save_select_feature_nonzero": true, "reservations": false, "timestamp": true, "verify": false, "copy": false, "other": 0 }, "nvme_log_page_attributes": { "value": 2, "smart_health_per_namespace": false, "commands_effects_log": true, "extended_get_log_page_cmd": false, "telemetry_log": false, "persistent_event_log": false, "supported_log_pages_log": false, "telemetry_data_area_4": false, "other": 0 }, "nvme_maximum_data_transfer_pages": 32, "nvme_composite_temperature_threshold": { "warning": 100, "critical": 110 }, "temperature": { "op_limit_max": 100, "critical_limit_max": 110, "current": 61 }, "nvme_power_states": [ { "non_operational_state": false, "relative_read_latency": 0, "relative_read_throughput": 0, "relative_write_latency": 0, "relative_write_throughput": 0, "entry_latency_us": 230000, "exit_latency_us": 50000, "max_power": { "value": 800, "scale": 2, "units_per_watt": 100 } }, { "non_operational_state": false, "relative_read_latency": 1, "relative_read_throughput": 1, "relative_write_latency": 1, "relative_write_throughput": 1, "entry_latency_us": 4000, "exit_latency_us": 50000, "max_power": { "value": 400, "scale": 2, "units_per_watt": 100 } }, { "non_operational_state": false, "relative_read_latency": 2, "relative_read_throughput": 2, "relative_write_latency": 2, "relative_write_throughput": 2, "entry_latency_us": 4000, "exit_latency_us": 250000, "max_power": { "value": 300, "scale": 2, "units_per_watt": 100 } }, { "non_operational_state": true, "relative_read_latency": 3, "relative_read_throughput": 3, "relative_write_latency": 3, "relative_write_throughput": 3, "entry_latency_us": 5000, "exit_latency_us": 10000, "max_power": { "value": 300, "scale": 1, "units_per_watt": 10000 } }, { "non_operational_state": true, "relative_read_latency": 4, "relative_read_throughput": 4, "relative_write_latency": 4, "relative_write_throughput": 4, "entry_latency_us": 54000, "exit_latency_us": 45000, "max_power": { "value": 50, "scale": 1, "units_per_watt": 10000 } } ], "smart_status": { "passed": true, "nvme": { "value": 0 } }, "nvme_smart_health_information_log": { "nsid": -1, "critical_warning": 0, "temperature": 61, "available_spare": 100, "available_spare_threshold": 32, "percentage_used": 0, "data_units_read": 6573104, "data_units_written": 16040567, "host_reads": 63241130, "host_writes": 253050006, "controller_busy_time": 0, "power_cycles": 430, "power_on_hours": 4399, "unsafe_shutdowns": 44, "media_errors": 0, "num_err_log_entries": 0, "warning_temp_time": 0, "critical_comp_time": 0 }, "spare_available": { "current_percent": 100, "threshold_percent": 32 }, "endurance_used": { "current_percent": 0 }, "power_cycle_count": 430, "power_on_time": { "hours": 4399 }, "nvme_error_information_log": { "size": 8, "read": 8, "unread": 0 }, "nvme_self_test_log": { "nsid": -1, "current_self_test_operation": { "value": 0, "string": "No self-test in progress" } } } ================================================ FILE: agent/test-data/smart/scan.json ================================================ { "json_format_version": [ 1, 0 ], "smartctl": { "version": [ 7, 5 ], "pre_release": false, "svn_revision": "5714", "platform_info": "x86_64-linux-6.17.1-2-cachyos", "build_info": "(local build)", "argv": [ "smartctl", "--scan", "-j" ], "exit_status": 0 }, "devices": [ { "name": "/dev/sda", "info_name": "/dev/sda [SAT]", "type": "sat", "protocol": "ATA" }, { "name": "/dev/nvme0", "info_name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe" } ] } ================================================ FILE: agent/test-data/smart/scsi.json ================================================ { "json_format_version": [ 1, 0 ], "smartctl": { "version": [ 7, 3 ], "svn_revision": "5338", "platform_info": "x86_64-linux-6.12.43+deb12-amd64", "build_info": "(local build)", "argv": [ "smartctl", "-aj", "/dev/sde" ], "exit_status": 0 }, "local_time": { "time_t": 1761502142, "asctime": "Sun Oct 21 21:09:02 2025 MSK" }, "device": { "name": "/dev/sde", "info_name": "/dev/sde", "type": "scsi", "protocol": "SCSI" }, "scsi_vendor": "YADRO", "scsi_product": "WUH721414AL4204", "scsi_model_name": "YADRO WUH721414AL4204", "scsi_revision": "C240", "scsi_version": "SPC-4", "user_capacity": { "blocks": 3418095616, "bytes": 14000519643136 }, "logical_block_size": 4096, "scsi_lb_provisioning": { "name": "fully provisioned", "value": 0, "management_enabled": { "name": "LBPME", "value": 0 }, "read_zeros": { "name": "LBPRZ", "value": 0 } }, "rotation_rate": 7200, "form_factor": { "scsi_value": 2, "name": "3.5 inches" }, "logical_unit_id": "0x5000cca29063dc00", "serial_number": "9YHSDH9B", "device_type": { "scsi_terminology": "Peripheral Device Type [PDT]", "scsi_value": 0, "name": "disk" }, "scsi_transport_protocol": { "name": "SAS (SPL-4)", "value": 6 }, "smart_support": { "available": true, "enabled": true }, "temperature_warning": { "enabled": true }, "smart_status": { "passed": true }, "temperature": { "current": 34, "drive_trip": 85 }, "power_on_time": { "hours": 458, "minutes": 25 }, "scsi_start_stop_cycle_counter": { "year_of_manufacture": "2022", "week_of_manufacture": "41", "specified_cycle_count_over_device_lifetime": 50000, "accumulated_start_stop_cycles": 2, "specified_load_unload_count_over_device_lifetime": 600000, "accumulated_load_unload_cycles": 418 }, "scsi_grown_defect_list": 0, "scsi_error_counter_log": { "read": { "errors_corrected_by_eccfast": 0, "errors_corrected_by_eccdelayed": 0, "errors_corrected_by_rereads_rewrites": 0, "total_errors_corrected": 0, "correction_algorithm_invocations": 346, "gigabytes_processed": "3,641", "total_uncorrected_errors": 0 }, "write": { "errors_corrected_by_eccfast": 0, "errors_corrected_by_eccdelayed": 0, "errors_corrected_by_rereads_rewrites": 0, "total_errors_corrected": 0, "correction_algorithm_invocations": 4052, "gigabytes_processed": "2124,590", "total_uncorrected_errors": 0 }, "verify": { "errors_corrected_by_eccfast": 0, "errors_corrected_by_eccdelayed": 0, "errors_corrected_by_rereads_rewrites": 0, "total_errors_corrected": 0, "correction_algorithm_invocations": 223, "gigabytes_processed": "0,000", "total_uncorrected_errors": 0 } } } ================================================ FILE: agent/test-data/smart/sda.json ================================================ { "json_format_version": [ 1, 0 ], "smartctl": { "version": [ 7, 5 ], "pre_release": false, "svn_revision": "5714", "platform_info": "x86_64-linux-6.17.1-2-cachyos", "build_info": "(local build)", "argv": [ "smartctl", "-aj", "/dev/sda" ], "drive_database_version": { "string": "7.5/5706" }, "messages": [ { "string": "Warning: This result is based on an Attribute check.", "severity": "warning" } ], "exit_status": 64 }, "local_time": { "time_t": 1761507466, "asctime": "Sun Oct 26 15:37:46 2025 EDT" }, "device": { "name": "/dev/sda", "info_name": "/dev/sda [SAT]", "type": "sat", "protocol": "ATA" }, "model_name": "P3-2TB", "serial_number": "9C40918040082", "firmware_version": "X0104A0", "user_capacity": { "blocks": 4000797360, "bytes": 2048408248320 }, "logical_block_size": 512, "physical_block_size": 512, "rotation_rate": 0, "form_factor": { "ata_value": 3, "name": "2.5 inches" }, "trim": { "supported": true, "deterministic": false, "zeroed": false }, "in_smartctl_database": false, "ata_version": { "string": "ACS-2 T13/2015-D revision 3", "major_value": 1008, "minor_value": 272 }, "sata_version": { "string": "SATA 3.2", "value": 255 }, "interface_speed": { "max": { "sata_value": 14, "string": "6.0 Gb/s", "units_per_second": 60, "bits_per_unit": 100000000 }, "current": { "sata_value": 3, "string": "6.0 Gb/s", "units_per_second": 60, "bits_per_unit": 100000000 } }, "smart_support": { "available": true, "enabled": true }, "smart_status": { "passed": true }, "ata_smart_data": { "offline_data_collection": { "status": { "value": 0, "string": "was never started" }, "completion_seconds": 120 }, "self_test": { "status": { "value": 0, "string": "completed without error", "passed": true }, "polling_minutes": { "short": 2, "extended": 10 } }, "capabilities": { "values": [ 17, 2 ], "exec_offline_immediate_supported": true, "offline_is_aborted_upon_new_cmd": false, "offline_surface_scan_supported": false, "self_tests_supported": true, "conveyance_self_test_supported": false, "selective_self_test_supported": false, "attribute_autosave_enabled": false, "error_logging_supported": true, "gp_logging_supported": true } }, "ata_sct_capabilities": { "value": 1, "error_recovery_control_supported": false, "feature_control_supported": false, "data_table_supported": false }, "ata_smart_attributes": { "revision": 1, "table": [ { "id": 1, "name": "Raw_Read_Error_Rate", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 5, "name": "Reallocated_Sector_Ct", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 9, "name": "Power_On_Hours", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 7344, "string": "7344" } }, { "id": 12, "name": "Power_Cycle_Count", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 104, "string": "104" } }, { "id": 160, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 161, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 51, "string": "PO--CK ", "prefailure": true, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 100, "string": "100" } }, { "id": 163, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 12, "string": "12" } }, { "id": 164, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 4140, "string": "4140" } }, { "id": 165, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 3, "string": "3" } }, { "id": 166, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 2, "string": "2" } }, { "id": 167, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 2, "string": "2" } }, { "id": 168, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 5050, "string": "5050" } }, { "id": 169, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 100, "string": "100" } }, { "id": 175, "name": "Program_Fail_Count_Chip", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 176, "name": "Erase_Fail_Count_Chip", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 177, "name": "Wear_Leveling_Count", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 178, "name": "Used_Rsvd_Blk_Cnt_Chip", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 181, "name": "Program_Fail_Cnt_Total", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 182, "name": "Erase_Fail_Count_Total", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 192, "name": "Power-Off_Retract_Count", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 98, "string": "98" } }, { "id": 194, "name": "Temperature_Celsius", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 34, "string": "-O---K ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": false, "auto_keep": true }, "raw": { "value": 31, "string": "31" } }, { "id": 195, "name": "Hardware_ECC_Recovered", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 295843, "string": "295843" } }, { "id": 196, "name": "Reallocated_Event_Count", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 197, "name": "Current_Pending_Sector", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 198, "name": "Offline_Uncorrectable", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 0, "string": "0" } }, { "id": 199, "name": "UDMA_CRC_Error_Count", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 131, "string": "131" } }, { "id": 232, "name": "Available_Reservd_Space", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 100, "string": "100" } }, { "id": 241, "name": "Total_LBAs_Written", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 48, "string": "----CK ", "prefailure": false, "updated_online": false, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 37763, "string": "37763" } }, { "id": 242, "name": "Total_LBAs_Read", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 48, "string": "----CK ", "prefailure": false, "updated_online": false, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 3928, "string": "3928" } }, { "id": 245, "name": "Unknown_Attribute", "value": 100, "worst": 100, "thresh": 50, "when_failed": "", "flags": { "value": 50, "string": "-O--CK ", "prefailure": false, "updated_online": true, "performance": false, "error_rate": false, "event_count": true, "auto_keep": true }, "raw": { "value": 25604, "string": "25604" } } ] }, "spare_available": { "current_percent": 100 }, "power_on_time": { "hours": 7344 }, "power_cycle_count": 104, "endurance_used": { "current_percent": 0 }, "temperature": { "current": 31 }, "ata_smart_error_log": { "summary": { "revision": 1, "count": 131, "logged_count": 5, "table": [ { "error_number": 129, "lifetime_hours": 0, "completion_registers": { "error": 4, "status": 81, "count": 0, "lba": 0, "device": 64 }, "error_description": "Error: ABRT", "previous_commands": [ { "registers": { "command": 176, "features": 208, "count": 1, "lba": 12734208, "device": 0, "device_control": 8 }, "powerup_milliseconds": 0, "command_name": "SMART READ DATA" }, { "registers": { "command": 176, "features": 209, "count": 1, "lba": 12734209, "device": 0, "device_control": 8 }, "powerup_milliseconds": 0, "command_name": "SMART READ ATTRIBUTE THRESHOLDS [OBS-4]" }, { "registers": { "command": 176, "features": 218, "count": 0, "lba": 12734208, "device": 0, "device_control": 8 }, "powerup_milliseconds": 0, "command_name": "SMART RETURN STATUS" }, { "registers": { "command": 176, "features": 213, "count": 1, "lba": 12734208, "device": 0, "device_control": 8 }, "powerup_milliseconds": 0, "command_name": "SMART READ LOG" }, { "registers": { "command": 236, "features": 0, "count": 1, "lba": 0, "device": 0, "device_control": 8 }, "powerup_milliseconds": 0, "command_name": "IDENTIFY DEVICE" } ] }, { "error_number": 127, "lifetime_hours": 0, "completion_registers": { "error": 0, "status": 0, "count": 0, "lba": 0, "device": 0 }, "error_description": " at LBA = 0x00000000 = 0", "previous_commands": [ { "registers": { "command": 97, "features": 8, "count": 0, "lba": 919080, "device": 0, "device_control": 0 }, "powerup_milliseconds": 0, "command_name": "WRITE FPDMA QUEUED" }, { "registers": { "command": 97, "features": 8, "count": 0, "lba": 919080, "device": 0, "device_control": 0 }, "powerup_milliseconds": 0, "command_name": "WRITE FPDMA QUEUED" }, { "registers": { "command": 97, "features": 8, "count": 0, "lba": 919080, "device": 0, "device_control": 0 }, "powerup_milliseconds": 0, "command_name": "WRITE FPDMA QUEUED" }, { "registers": { "command": 97, "features": 8, "count": 0, "lba": 919080, "device": 0, "device_control": 0 }, "powerup_milliseconds": 0, "command_name": "WRITE FPDMA QUEUED" }, { "registers": { "command": 97, "features": 8, "count": 0, "lba": 919080, "device": 0, "device_control": 0 }, "powerup_milliseconds": 0, "command_name": "WRITE FPDMA QUEUED" } ] } ] } }, "ata_smart_self_test_log": { "standard": { "revision": 1, "table": [ { "type": { "value": 1, "string": "Short offline" }, "status": { "value": 23, "string": "Aborted by host", "remaining_percent": 70 }, "lifetime_hours": 0 }, { "type": { "value": 1, "string": "Short offline" }, "status": { "value": 23, "string": "Aborted by host", "remaining_percent": 70 }, "lifetime_hours": 0 }, { "type": { "value": 1, "string": "Short offline" }, "status": { "value": 23, "string": "Aborted by host", "remaining_percent": 70 }, "lifetime_hours": 0 } ], "count": 3, "error_count_total": 0, "error_count_outdated": 0 } } } ================================================ FILE: agent/test-data/system_info.json ================================================ { "ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS", "Containers": 14, "ContainersRunning": 3, "ContainersPaused": 1, "ContainersStopped": 10, "Images": 508, "Driver": "overlay2", "KernelVersion": "6.8.0-31-generic", "OperatingSystem": "Ubuntu 24.04 LTS", "OSVersion": "24.04", "OSType": "linux", "Architecture": "x86_64", "NCPU": 4, "MemTotal": 2095882240, "ServerVersion": "27.0.1" } ================================================ FILE: agent/tools/fetchsmartctl/main.go ================================================ package main import ( "crypto/sha1" "crypto/sha256" "encoding/hex" "flag" "fmt" "hash" "io" "net/http" "os" "path/filepath" "strings" "time" ) // Download smartctl.exe from the given URL and save it to the given destination. // This is used to embed smartctl.exe in the Windows build. func main() { url := flag.String("url", "", "URL to download smartctl.exe from (required)") out := flag.String("out", "", "Destination path for smartctl.exe (required)") sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation") force := flag.Bool("force", false, "Force re-download even if destination exists") flag.Parse() if *url == "" || *out == "" { fatalf("-url and -out are required") } if !*force { if info, err := os.Stat(*out); err == nil && info.Size() > 0 { fmt.Println("smartctl.exe already present, skipping download") return } } if err := downloadFile(*url, *out, *sha); err != nil { fatalf("download failed: %v", err) } } func downloadFile(url, dest, shaHex string) error { // Prepare destination if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return fmt.Errorf("create dir: %w", err) } // HTTP client client := &http.Client{Timeout: 60 * time.Second} req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return fmt.Errorf("new request: %w", err) } req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0") resp, err := client.Do(req) if err != nil { return fmt.Errorf("http get: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("unexpected HTTP status: %s", resp.Status) } tmp := dest + ".tmp" f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("open tmp: %w", err) } // Determine hash algorithm based on length (SHA1=40, SHA256=64) var hasher hash.Hash if shaHex := strings.TrimSpace(shaHex); shaHex != "" { cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", "")) switch len(cleanSha) { case 40: hasher = sha1.New() case 64: hasher = sha256.New() default: f.Close() os.Remove(tmp) return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha)) } } var mw io.Writer = f if hasher != nil { mw = io.MultiWriter(f, hasher) } if _, err := io.Copy(mw, resp.Body); err != nil { f.Close() os.Remove(tmp) return fmt.Errorf("write tmp: %w", err) } if err := f.Close(); err != nil { os.Remove(tmp) return fmt.Errorf("close tmp: %w", err) } if hasher != nil && shaHex != "" { cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", "")) got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil))) if got != cleanSha { os.Remove(tmp) return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha) } } // Make executable and move into place if err := os.Chmod(tmp, 0o755); err != nil { os.Remove(tmp) return fmt.Errorf("chmod: %w", err) } if err := os.Rename(tmp, dest); err != nil { os.Remove(tmp) return fmt.Errorf("rename: %w", err) } fmt.Println("smartctl.exe downloaded to", dest) return nil } func fatalf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) os.Exit(1) } ================================================ FILE: agent/update.go ================================================ package agent import ( "log" "os" "os/exec" "runtime" "github.com/henrygd/beszel/internal/ghupdate" ) // restarter knows how to restart the beszel-agent service. type restarter interface { Restart() error } type systemdRestarter struct{ cmd string } func (s *systemdRestarter) Restart() error { // Only restart if the service is active if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil { return nil } ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…") return exec.Command(s.cmd, "restart", "beszel-agent.service").Run() } type openRCRestarter struct{ cmd string } func (o *openRCRestarter) Restart() error { if err := exec.Command(o.cmd, "beszel-agent", "status").Run(); err != nil { return nil } ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…") return exec.Command(o.cmd, "beszel-agent", "restart").Run() } type openWRTRestarter struct{ cmd string } func (w *openWRTRestarter) Restart() error { // https://openwrt.org/docs/guide-user/base-system/managing_services?s[]=service if err := exec.Command("/etc/init.d/beszel-agent", "running").Run(); err != nil { return nil } ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…") return exec.Command("/etc/init.d/beszel-agent", "restart").Run() } type freeBSDRestarter struct{ cmd string } func (f *freeBSDRestarter) Restart() error { if err := exec.Command(f.cmd, "beszel-agent", "status").Run(); err != nil { return nil } ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via FreeBSD rc…") return exec.Command(f.cmd, "beszel-agent", "restart").Run() } func detectRestarter() restarter { if path, err := exec.LookPath("systemctl"); err == nil { return &systemdRestarter{cmd: path} } if path, err := exec.LookPath("rc-service"); err == nil { return &openRCRestarter{cmd: path} } if path, err := exec.LookPath("procd"); err == nil { return &openWRTRestarter{cmd: path} } if path, err := exec.LookPath("service"); err == nil { if runtime.GOOS == "freebsd" { return &freeBSDRestarter{cmd: path} } } return nil } // Update checks GitHub for a newer release of beszel-agent, applies it, // fixes SELinux context if needed, and restarts the service. func Update(useMirror bool) error { exePath, _ := os.Executable() dataDir, err := GetDataDir() if err != nil { dataDir = os.TempDir() } updated, err := ghupdate.Update(ghupdate.Config{ ArchiveExecutable: "beszel-agent", DataDir: dataDir, UseMirror: useMirror, }) if err != nil { log.Fatal(err) } if !updated { return nil } // make sure the file is executable if err := os.Chmod(exePath, 0755); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err) } // set ownership to beszel:beszel if possible if chownPath, err := exec.LookPath("chown"); err == nil { if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err) } } // Fix SELinux context if necessary if err := ghupdate.HandleSELinuxContext(exePath); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) } // Restart service if running under a recognised init system if r := detectRestarter(); r != nil { if err := r.Restart(); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err) ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.") } else { ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully") } } else { ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.") } return nil } ================================================ FILE: agent/utils/utils.go ================================================ package utils import ( "io" "math" "os" "strconv" "strings" ) // GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key. func GetEnv(key string) (value string, exists bool) { if value, exists = os.LookupEnv("BESZEL_AGENT_" + key); exists { return value, exists } return os.LookupEnv(key) } // BytesToMegabytes converts bytes to megabytes and rounds to two decimal places. func BytesToMegabytes(b float64) float64 { return TwoDecimals(b / 1048576) } // BytesToGigabytes converts bytes to gigabytes and rounds to two decimal places. func BytesToGigabytes(b uint64) float64 { return TwoDecimals(float64(b) / 1073741824) } // TwoDecimals rounds a float64 value to two decimal places. func TwoDecimals(value float64) float64 { return math.Round(value*100) / 100 } // func RoundFloat(val float64, precision uint) float64 { // ratio := math.Pow(10, float64(precision)) // return math.Round(val*ratio) / ratio // } // ReadStringFile returns trimmed file contents or empty string on error. func ReadStringFile(path string) string { content, _ := ReadStringFileOK(path) return content } // ReadStringFileOK returns trimmed file contents and read success. func ReadStringFileOK(path string) (string, bool) { b, err := os.ReadFile(path) if err != nil { return "", false } return strings.TrimSpace(string(b)), true } // ReadStringFileLimited reads a file into a string with a maximum size (in bytes) to avoid // allocating large buffers and potential panics with pseudo-files when the size is misreported. func ReadStringFileLimited(path string, maxSize int) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() buf := make([]byte, maxSize) n, err := f.Read(buf) if err != nil && err != io.EOF { return "", err } return strings.TrimSpace(string(buf[:n])), nil } // FileExists reports whether the given path exists. func FileExists(path string) bool { _, err := os.Stat(path) return err == nil } // ReadUintFile parses a decimal uint64 value from a file. func ReadUintFile(path string) (uint64, bool) { raw, ok := ReadStringFileOK(path) if !ok { return 0, false } parsed, err := strconv.ParseUint(raw, 10, 64) if err != nil { return 0, false } return parsed, true } ================================================ FILE: agent/utils/utils_test.go ================================================ package utils import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestTwoDecimals(t *testing.T) { tests := []struct { name string input float64 expected float64 }{ {"round down", 1.234, 1.23}, {"round half up", 1.235, 1.24}, // math.Round rounds half up {"no rounding needed", 1.23, 1.23}, {"negative number", -1.235, -1.24}, // math.Round rounds half up (more negative) {"zero", 0.0, 0.0}, {"large number", 123.456, 123.46}, // rounds 5 up } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := TwoDecimals(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestBytesToMegabytes(t *testing.T) { tests := []struct { name string input float64 expected float64 }{ {"1 MB", 1048576, 1.0}, {"512 KB", 524288, 0.5}, {"zero", 0, 0}, {"large value", 1073741824, 1024}, // 1 GB = 1024 MB } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := BytesToMegabytes(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestBytesToGigabytes(t *testing.T) { tests := []struct { name string input uint64 expected float64 }{ {"1 GB", 1073741824, 1.0}, {"512 MB", 536870912, 0.5}, {"0 GB", 0, 0}, {"2 GB", 2147483648, 2.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := BytesToGigabytes(tt.input) assert.Equal(t, tt.expected, result) }) } } func TestFileFunctions(t *testing.T) { tmpDir := t.TempDir() testFilePath := filepath.Join(tmpDir, "test.txt") testContent := "hello world" // Test FileExists (false) assert.False(t, FileExists(testFilePath)) // Test ReadStringFileOK (false) content, ok := ReadStringFileOK(testFilePath) assert.False(t, ok) assert.Empty(t, content) // Test ReadStringFile (empty) assert.Empty(t, ReadStringFile(testFilePath)) // Write file err := os.WriteFile(testFilePath, []byte(testContent+"\n "), 0644) assert.NoError(t, err) // Test FileExists (true) assert.True(t, FileExists(testFilePath)) // Test ReadStringFileOK (true) content, ok = ReadStringFileOK(testFilePath) assert.True(t, ok) assert.Equal(t, testContent, content) // Test ReadStringFile (content) assert.Equal(t, testContent, ReadStringFile(testFilePath)) } func TestReadUintFile(t *testing.T) { tmpDir := t.TempDir() t.Run("valid uint", func(t *testing.T) { path := filepath.Join(tmpDir, "uint.txt") os.WriteFile(path, []byte(" 12345\n"), 0644) val, ok := ReadUintFile(path) assert.True(t, ok) assert.Equal(t, uint64(12345), val) }) t.Run("invalid uint", func(t *testing.T) { path := filepath.Join(tmpDir, "invalid.txt") os.WriteFile(path, []byte("abc"), 0644) val, ok := ReadUintFile(path) assert.False(t, ok) assert.Equal(t, uint64(0), val) }) t.Run("missing file", func(t *testing.T) { path := filepath.Join(tmpDir, "missing.txt") val, ok := ReadUintFile(path) assert.False(t, ok) assert.Equal(t, uint64(0), val) }) } func TestGetEnv(t *testing.T) { key := "TEST_VAR" prefixedKey := "BESZEL_AGENT_" + key t.Run("prefixed variable exists", func(t *testing.T) { os.Setenv(prefixedKey, "prefixed_val") os.Setenv(key, "unprefixed_val") defer os.Unsetenv(prefixedKey) defer os.Unsetenv(key) val, exists := GetEnv(key) assert.True(t, exists) assert.Equal(t, "prefixed_val", val) }) t.Run("only unprefixed variable exists", func(t *testing.T) { os.Unsetenv(prefixedKey) os.Setenv(key, "unprefixed_val") defer os.Unsetenv(key) val, exists := GetEnv(key) assert.True(t, exists) assert.Equal(t, "unprefixed_val", val) }) t.Run("neither variable exists", func(t *testing.T) { os.Unsetenv(prefixedKey) os.Unsetenv(key) val, exists := GetEnv(key) assert.False(t, exists) assert.Empty(t, val) }) } ================================================ FILE: agent/zfs/zfs_freebsd.go ================================================ //go:build freebsd package zfs import ( "golang.org/x/sys/unix" ) func ARCSize() (uint64, error) { return unix.SysctlUint64("kstat.zfs.misc.arcstats.size") } ================================================ FILE: agent/zfs/zfs_linux.go ================================================ //go:build linux // Package zfs provides functions to read ZFS statistics. package zfs import ( "bufio" "fmt" "os" "strconv" "strings" ) func ARCSize() (uint64, error) { file, err := os.Open("/proc/spl/kstat/zfs/arcstats") if err != nil { return 0, err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() if strings.HasPrefix(line, "size") { fields := strings.Fields(line) if len(fields) < 3 { return 0, fmt.Errorf("unexpected arcstats size format: %s", line) } return strconv.ParseUint(fields[2], 10, 64) } } return 0, fmt.Errorf("size field not found in arcstats") } ================================================ FILE: agent/zfs/zfs_unsupported.go ================================================ //go:build !linux && !freebsd package zfs import "errors" func ARCSize() (uint64, error) { return 0, errors.ErrUnsupported } ================================================ FILE: beszel.go ================================================ // Package beszel provides core application constants and version information // which are used throughout the application. package beszel import "github.com/blang/semver" const ( // Version is the current version of the application. Version = "0.18.4" // AppName is the name of the application. AppName = "beszel" ) // MinVersionCbor is the minimum supported version for CBOR compatibility. var MinVersionCbor = semver.MustParse("0.12.0") // MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility. var MinVersionAgentResponse = semver.MustParse("0.13.0") ================================================ FILE: go.mod ================================================ module github.com/henrygd/beszel go 1.26.1 require ( github.com/blang/semver v3.5.1+incompatible github.com/coreos/go-systemd/v22 v22.7.0 github.com/distatus/battery v0.11.0 github.com/ebitengine/purego v0.9.1 github.com/fxamacker/cbor/v2 v2.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/google/uuid v1.6.0 github.com/lxzan/gws v1.8.9 github.com/nicholas-fedor/shoutrrr v0.13.2 github.com/pocketbase/dbx v1.12.0 github.com/pocketbase/pocketbase v0.36.4 github.com/shirou/gopsutil/v4 v4.26.1 github.com/spf13/cast v1.10.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.48.0 golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa golang.org/x/sys v0.41.0 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/disintegration/imaging v1.6.2 // indirect github.com/dolthub/maphash v0.1.0 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // 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/go-sql-driver/mysql v1.9.1 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/image v0.36.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect howett.net/plist v1.0.1 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.45.0 // indirect ) ================================================ FILE: go.sum ================================================ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/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/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/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= 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.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= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc= github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.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.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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= 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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA= github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= 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.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4= github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI= 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/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.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/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/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= 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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.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.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= 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= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: i18n.yml ================================================ files: - source: /internal/site/src/locales/en/ translation: /internal/site/src/locales/%two_letters_code%/%two_letters_code%.po ================================================ FILE: internal/alerts/alerts.go ================================================ // Package alerts handles alert management and delivery. package alerts import ( "fmt" "net/mail" "net/url" "sync" "time" "github.com/nicholas-fedor/shoutrrr" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/mailer" ) type hubLike interface { core.App MakeLink(parts ...string) string } type AlertManager struct { hub hubLike stopOnce sync.Once pendingAlerts sync.Map alertsCache *AlertsCache } type AlertMessageData struct { UserID string SystemID string Title string Message string Link string LinkText string } type UserNotificationSettings struct { Emails []string `json:"emails"` Webhooks []string `json:"webhooks"` } type SystemAlertFsStats struct { DiskTotal float64 `json:"d"` DiskUsed float64 `json:"du"` } // Values pulled from system_stats.stats that are relevant to alerts. type SystemAlertStats struct { Cpu float64 `json:"cpu"` Mem float64 `json:"mp"` Disk float64 `json:"dp"` Bandwidth [2]uint64 `json:"b"` GPU map[string]SystemAlertGPUData `json:"g"` Temperatures map[string]float32 `json:"t"` LoadAvg [3]float64 `json:"la"` Battery [2]uint8 `json:"bat"` ExtraFs map[string]SystemAlertFsStats `json:"efs"` } type SystemAlertGPUData struct { Usage float64 `json:"u"` } type SystemAlertData struct { systemRecord *core.Record alertData CachedAlertData name string unit string val float64 threshold float64 triggered bool time time.Time count uint8 min uint8 mapSums map[string]float32 descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc) } // notification services that support title param var supportsTitle = map[string]struct{}{ "bark": {}, "discord": {}, "gotify": {}, "ifttt": {}, "join": {}, "lark": {}, "ntfy": {}, "opsgenie": {}, "pushbullet": {}, "pushover": {}, "slack": {}, "teams": {}, "telegram": {}, "zulip": {}, } // NewAlertManager creates a new AlertManager instance. func NewAlertManager(app hubLike) *AlertManager { am := &AlertManager{ hub: app, alertsCache: NewAlertsCache(app), } am.bindEvents() return am } // Bind events to the alerts collection lifecycle func (am *AlertManager) bindEvents() { am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate) am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete) am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert) am.hub.OnServe().BindFunc(func(e *core.ServeEvent) error { // Populate all alerts into cache on startup _ = am.alertsCache.PopulateFromDB(true) if err := resolveStatusAlerts(e.App); err != nil { e.App.Logger().Error("Failed to resolve stale status alerts", "err", err) } if err := am.restorePendingStatusAlerts(); err != nil { e.App.Logger().Error("Failed to restore pending status alerts", "err", err) } return e.Next() }) } // IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool { // Query for quiet hours windows that match this user and system // Include both global windows (system is null/empty) and system-specific windows var filter string var params dbx.Params if systemID == "" { // If no systemID provided, only check global windows filter = "user={:user} AND system=''" params = dbx.Params{"user": userID} } else { // Check both global and system-specific windows filter = "user={:user} AND (system='' OR system={:system})" params = dbx.Params{ "user": userID, "system": systemID, } } quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params)) if err != nil || len(quietHourWindows) == 0 { return false } now := time.Now().UTC() for _, window := range quietHourWindows { windowType := window.GetString("type") start := window.GetDateTime("start").Time() end := window.GetDateTime("end").Time() if windowType == "daily" { // For daily recurring windows, extract just the time portion and compare // The start/end are stored as full datetime but we only care about HH:MM startHour, startMin, _ := start.Clock() endHour, endMin, _ := end.Clock() nowHour, nowMin, _ := now.Clock() // Convert to minutes since midnight for easier comparison startMinutes := startHour*60 + startMin endMinutes := endHour*60 + endMin nowMinutes := nowHour*60 + nowMin // Handle case where window crosses midnight if endMinutes < startMinutes { // Window crosses midnight (e.g., 23:00 - 01:00) if nowMinutes >= startMinutes || nowMinutes < endMinutes { return true } } else { // Normal case (e.g., 09:00 - 17:00) if nowMinutes >= startMinutes && nowMinutes < endMinutes { return true } } } else { // One-time window: check if current time is within the date range if (now.After(start) || now.Equal(start)) && now.Before(end) { return true } } } return false } // SendAlert sends an alert to the user func (am *AlertManager) SendAlert(data AlertMessageData) error { // Check if alert is silenced if am.IsNotificationSilenced(data.UserID, data.SystemID) { am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title) return nil } // get user settings record, err := am.hub.FindFirstRecordByFilter( "user_settings", "user={:user}", dbx.Params{"user": data.UserID}, ) if err != nil { return err } // unmarshal user settings userAlertSettings := UserNotificationSettings{ Emails: []string{}, Webhooks: []string{}, } if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil { 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.hub.Logger().Error("Failed to send shoutrrr alert", "err", err) } } // send alerts via email if len(userAlertSettings.Emails) == 0 { return nil } addresses := []mail.Address{} for _, email := range userAlertSettings.Emails { addresses = append(addresses, mail.Address{Address: email}) } message := mailer.Message{ To: addresses, Subject: data.Title, Text: data.Message + fmt.Sprintf("\n\n%s", data.Link), From: mail.Address{ Address: am.hub.Settings().Meta.SenderAddress, Name: am.hub.Settings().Meta.SenderName, }, } err = am.hub.NewMailClient().Send(&message) if err != nil { return err } am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject) return nil } // SendShoutrrrAlert sends an alert via a Shoutrrr URL func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error { // Parse the URL parsedURL, err := url.Parse(notificationUrl) if err != nil { return fmt.Errorf("error parsing URL: %v", err) } scheme := parsedURL.Scheme queryParams := parsedURL.Query() // Add title if _, ok := supportsTitle[scheme]; ok { queryParams.Add("title", title) } else if scheme == "mattermost" { // use markdown title for mattermost message = "##### " + title + "\n\n" + message } else if scheme == "generic" && queryParams.Has("template") { // add title as property if using generic with template json titleKey := queryParams.Get("titlekey") if titleKey == "" { titleKey = "title" } queryParams.Add("$"+titleKey, title) } else { // otherwise just add title to message message = title + "\n\n" + message } // Add link switch scheme { case "ntfy": queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link)) case "lark": queryParams.Add("link", link) case "bark": queryParams.Add("url", link) default: message += "\n\n" + link } // Encode the modified query parameters back into the URL parsedURL.RawQuery = queryParams.Encode() // log.Println("URL after modification:", parsedURL.String()) err = shoutrrr.Send(parsedURL.String(), message) if err == nil { am.hub.Logger().Info("Sent shoutrrr alert", "title", title) } else { am.hub.Logger().Error("Error sending shoutrrr alert", "err", err) return err } return nil } func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error { var data struct { URL string `json:"url"` } err := e.BindBody(&data) if err != nil || data.URL == "" { return e.BadRequestError("URL is required", err) } err = am.SendShoutrrrAlert(data.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()}) } return e.JSON(200, map[string]bool{"err": false}) } // setAlertTriggered updates the "triggered" status of an alert record in the database func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error { alertRecord, err := am.hub.FindRecordById("alerts", alert.Id) if err != nil { return err } alertRecord.Set("triggered", triggered) return am.hub.Save(alertRecord) } ================================================ FILE: internal/alerts/alerts_api.go ================================================ package alerts import ( "database/sql" "errors" "net/http" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" ) // UpsertUserAlerts handles API request to create or update alerts for a user // across multiple systems (POST /api/beszel/user-alerts) func UpsertUserAlerts(e *core.RequestEvent) error { userID := e.Auth.Id reqData := struct { Min uint8 `json:"min"` Value float64 `json:"value"` Name string `json:"name"` Systems []string `json:"systems"` Overwrite bool `json:"overwrite"` }{} err := e.BindBody(&reqData) if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 { return e.BadRequestError("Bad data", err) } alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts") if err != nil { return err } err = e.App.RunInTransaction(func(txApp core.App) error { for _, systemId := range reqData.Systems { // find existing matching alert alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection, "system={:system} && name={:name} && user={:user}", dbx.Params{"system": systemId, "name": reqData.Name, "user": userID}) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } // skip if alert already exists and overwrite is not set if !reqData.Overwrite && alertRecord != nil { continue } // create new alert if it doesn't exist if alertRecord == nil { alertRecord = core.NewRecord(alertsCollection) alertRecord.Set("user", userID) alertRecord.Set("system", systemId) alertRecord.Set("name", reqData.Name) } alertRecord.Set("value", reqData.Value) alertRecord.Set("min", reqData.Min) if err := txApp.SaveNoValidate(alertRecord); err != nil { return err } } return nil }) if err != nil { return err } return e.JSON(http.StatusOK, map[string]any{"success": true}) } // DeleteUserAlerts handles API request to delete alerts for a user across multiple systems // (DELETE /api/beszel/user-alerts) func DeleteUserAlerts(e *core.RequestEvent) error { userID := e.Auth.Id reqData := struct { AlertName string `json:"name"` Systems []string `json:"systems"` }{} err := e.BindBody(&reqData) if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 { return e.BadRequestError("Bad data", err) } var numDeleted uint16 err = e.App.RunInTransaction(func(txApp core.App) error { for _, systemId := range reqData.Systems { // Find existing alert to delete alertRecord, err := txApp.FindFirstRecordByFilter("alerts", "system={:system} && name={:name} && user={:user}", dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID}) if err != nil { if errors.Is(err, sql.ErrNoRows) { // alert doesn't exist, continue to next system continue } return err } if err := txApp.Delete(alertRecord); err != nil { return err } numDeleted++ } return nil }) if err != nil { return err } return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted}) } ================================================ FILE: internal/alerts/alerts_battery_test.go ================================================ //go:build testing package alerts_test import ( "encoding/json" "testing" "time" "github.com/henrygd/beszel/internal/entities/system" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/tools/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold // (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold) func TestBatteryAlertLogic(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] // Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger) batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Battery", "system": systemRecord.Id, "user": user.Id, "value": 20, // threshold: 20% "min": 1, // 1 minute (immediate trigger for testing) }) require.NoError(t, err) // Verify alert is not triggered initially assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially") // Create system stats with battery at 50% (above threshold - should NOT trigger) statsHigh := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{50, 1}, // 50% battery, discharging } statsHighJSON, _ := json.Marshal(statsHigh) _, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsHighJSON), }) require.NoError(t, err) // Create CombinedData for the alert handler combinedDataHigh := &system.CombinedData{ Stats: statsHigh, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Simulate system update time systemRecord.Set("updated", time.Now().UTC()) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts with high battery am := hub.GetAlertManager() err = am.HandleSystemAlerts(systemRecord, combinedDataHigh) require.NoError(t, err) // Verify alert is still NOT triggered (battery 50% is above threshold 20%) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)") // Now create stats with battery at 15% (below threshold - should trigger) statsLow := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{15, 1}, // 15% battery, discharging } statsLowJSON, _ := json.Marshal(statsLow) _, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsLowJSON), }) require.NoError(t, err) combinedDataLow := &system.CombinedData{ Stats: statsLow, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Update system timestamp systemRecord.Set("updated", time.Now().UTC()) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts with low battery err = am.HandleSystemAlerts(systemRecord, combinedDataLow) require.NoError(t, err) // Wait for the alert to be processed time.Sleep(20 * time.Millisecond) // Verify alert IS triggered (battery 15% is below threshold 20%) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)") // Now test resolution: battery goes back above threshold statsRecovered := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{25, 1}, // 25% battery, discharging } statsRecoveredJSON, _ := json.Marshal(statsRecovered) _, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsRecoveredJSON), }) require.NoError(t, err) combinedDataRecovered := &system.CombinedData{ Stats: statsRecovered, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Update system timestamp systemRecord.Set("updated", time.Now().UTC()) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts with recovered battery err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered) require.NoError(t, err) // Wait for the alert to be processed time.Sleep(20 * time.Millisecond) // Verify alert is now resolved (battery 25% is above threshold 20%) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)") } // TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts func TestBatteryAlertNoBattery(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] // Create a battery alert batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Battery", "system": systemRecord.Id, "user": user.Id, "value": 20, "min": 1, }) require.NoError(t, err) // Create stats with NO battery data (Battery[0] = 0) statsNoBattery := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{0, 0}, // No battery } combinedData := &system.CombinedData{ Stats: statsNoBattery, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Simulate system update time systemRecord.Set("updated", time.Now().UTC()) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts am := hub.GetAlertManager() err = am.HandleSystemAlerts(systemRecord, combinedData) require.NoError(t, err) // Wait a moment for processing time.Sleep(20 * time.Millisecond) // Verify alert is NOT triggered (no battery data should skip the alert) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery") } // TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples) // This ensures the inverted threshold logic works correctly across averaged time windows func TestBatteryAlertAveragedSamples(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] // Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging) batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Battery", "system": systemRecord.Id, "user": user.Id, "value": 25, // threshold: 25% "min": 2, // 2 minutes - requires averaging }) require.NoError(t, err) // Verify alert is not triggered initially assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially") am := hub.GetAlertManager() now := time.Now().UTC() // Create system_stats records with low battery (below threshold) // The alert has min=2 minutes, so alert.time = now - 2 minutes // For the alert to be valid, alert.time must be AFTER the oldest record's created time // So we need records older than (now - 2 min), plus records within the window // Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s recordTimes := []time.Duration{ -180 * time.Second, // 3 min ago - this makes the oldest record before alert.time -90 * time.Second, -60 * time.Second, -30 * time.Second, } for _, offset := range recordTimes { statsLow := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold) } statsLowJSON, _ := json.Marshal(statsLow) recordTime := now.Add(offset) record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsLowJSON), }) require.NoError(t, err) // Update created time to simulate historical records - use SetRaw with formatted string record.SetRaw("created", recordTime.Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) } // Create combined data with low battery combinedDataLow := &system.CombinedData{ Stats: system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{15, 1}, }, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Update system timestamp systemRecord.Set("updated", now) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts - should trigger because average battery is below threshold err = am.HandleSystemAlerts(systemRecord, combinedDataLow) require.NoError(t, err) // Wait for alert processing time.Sleep(20 * time.Millisecond) // Verify alert IS triggered (average battery 15% is below threshold 25%) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period") // Now add records with high battery to test resolution // Use a new time window 2 minutes later newNow := now.Add(2 * time.Minute) // Records need to span before the alert time window (newNow - 2 min) recordTimesHigh := []time.Duration{ -180 * time.Second, // 3 min before newNow - makes oldest record before alert.time -90 * time.Second, -60 * time.Second, -30 * time.Second, } for _, offset := range recordTimesHigh { statsHigh := system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold) } statsHighJSON, _ := json.Marshal(statsHigh) recordTime := newNow.Add(offset) record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsHighJSON), }) require.NoError(t, err) record.SetRaw("created", recordTime.Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) } // Create combined data with high battery combinedDataHigh := &system.CombinedData{ Stats: system.Stats{ Cpu: 10, MemPct: 30, DiskPct: 40, Battery: [2]uint8{50, 1}, }, Info: system.Info{ AgentVersion: "0.12.0", Cpu: 10, MemPct: 30, DiskPct: 40, }, } // Update system timestamp to the new time window systemRecord.Set("updated", newNow) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) // Handle system alerts - should resolve because average battery is now above threshold err = am.HandleSystemAlerts(systemRecord, combinedDataHigh) require.NoError(t, err) // Wait for alert processing time.Sleep(20 * time.Millisecond) // Verify alert is resolved (average battery 50% is above threshold 25%) batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id}) require.NoError(t, err) assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period") } ================================================ FILE: internal/alerts/alerts_cache.go ================================================ package alerts import ( "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/store" ) // CachedAlertData represents the relevant fields of an alert record for status checking and updates. type CachedAlertData struct { Id string SystemID string UserID string Name string Value float64 Triggered bool Min uint8 // Created types.DateTime } func (a *CachedAlertData) PopulateFromRecord(record *core.Record) { a.Id = record.Id a.SystemID = record.GetString("system") a.UserID = record.GetString("user") a.Name = record.GetString("name") a.Value = record.GetFloat("value") a.Triggered = record.GetBool("triggered") a.Min = uint8(record.GetInt("min")) // a.Created = record.GetDateTime("created") } // AlertsCache provides an in-memory cache for system alerts. type AlertsCache struct { app core.App store *store.Store[string, *store.Store[string, CachedAlertData]] populated bool } // NewAlertsCache creates a new instance of SystemAlertsCache. func NewAlertsCache(app core.App) *AlertsCache { c := AlertsCache{ app: app, store: store.New(map[string]*store.Store[string, CachedAlertData]{}), } return c.bindEvents() } // bindEvents sets up event listeners to keep the cache in sync with database changes. func (c *AlertsCache) bindEvents() *AlertsCache { c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error { // c.Delete(e.Record.Original()) // this would be needed if the system field on an existing alert was changed, however we don't currently allow that in the UI so we'll leave it commented out c.Update(e.Record) return e.Next() }) c.app.OnRecordAfterDeleteSuccess("alerts").BindFunc(func(e *core.RecordEvent) error { c.Delete(e.Record) return e.Next() }) c.app.OnRecordAfterCreateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error { c.Update(e.Record) return e.Next() }) return c } // PopulateFromDB clears current entries and loads all alerts from the database into the cache. func (c *AlertsCache) PopulateFromDB(force bool) error { if !force && c.populated { return nil } records, err := c.app.FindAllRecords("alerts") if err != nil { return err } c.store.RemoveAll() for _, record := range records { c.Update(record) } c.populated = true return nil } // Update adds or updates an alert record in the cache. func (c *AlertsCache) Update(record *core.Record) { systemID := record.GetString("system") if systemID == "" { return } systemStore, ok := c.store.GetOk(systemID) if !ok { systemStore = store.New(map[string]CachedAlertData{}) c.store.Set(systemID, systemStore) } var ca CachedAlertData ca.PopulateFromRecord(record) systemStore.Set(record.Id, ca) } // Delete removes an alert record from the cache. func (c *AlertsCache) Delete(record *core.Record) { systemID := record.GetString("system") if systemID == "" { return } if systemStore, ok := c.store.GetOk(systemID); ok { systemStore.Remove(record.Id) } } // GetSystemAlerts returns all alerts for the specified system, lazy-loading if necessary. func (c *AlertsCache) GetSystemAlerts(systemID string) []CachedAlertData { systemStore, ok := c.store.GetOk(systemID) if !ok { // Populate cache for this system records, err := c.app.FindAllRecords("alerts", dbx.NewExp("system={:system}", dbx.Params{"system": systemID})) if err != nil { return nil } systemStore = store.New(map[string]CachedAlertData{}) for _, record := range records { var ca CachedAlertData ca.PopulateFromRecord(record) systemStore.Set(record.Id, ca) } c.store.Set(systemID, systemStore) } all := systemStore.GetAll() alerts := make([]CachedAlertData, 0, len(all)) for _, alert := range all { alerts = append(alerts, alert) } return alerts } // GetAlert returns a specific alert by its ID from the cache. func (c *AlertsCache) GetAlert(systemID, alertID string) (CachedAlertData, bool) { if systemStore, ok := c.store.GetOk(systemID); ok { return systemStore.GetOk(alertID) } return CachedAlertData{}, false } // GetAlertsByName returns all alerts of a specific type for the specified system. func (c *AlertsCache) GetAlertsByName(systemID, alertName string) []CachedAlertData { allAlerts := c.GetSystemAlerts(systemID) var alerts []CachedAlertData for _, record := range allAlerts { if record.Name == alertName { alerts = append(alerts, record) } } return alerts } // GetAlertsExcludingNames returns all alerts for the specified system excluding the given types. func (c *AlertsCache) GetAlertsExcludingNames(systemID string, excludedNames ...string) []CachedAlertData { excludeMap := make(map[string]struct{}) for _, name := range excludedNames { excludeMap[name] = struct{}{} } allAlerts := c.GetSystemAlerts(systemID) var alerts []CachedAlertData for _, record := range allAlerts { if _, excluded := excludeMap[record.Name]; !excluded { alerts = append(alerts, record) } } return alerts } // Refresh returns the latest cached copy for an alert snapshot if it still exists. func (c *AlertsCache) Refresh(alert CachedAlertData) (CachedAlertData, bool) { if alert.Id == "" { return CachedAlertData{}, false } return c.GetAlert(alert.SystemID, alert.Id) } ================================================ FILE: internal/alerts/alerts_cache_test.go ================================================ //go:build testing package alerts_test import ( "testing" "github.com/henrygd/beszel/internal/alerts" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSystemAlertsCachePopulateAndFilter(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up") require.NoError(t, err) system1 := systems[0] system2 := systems[1] statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system1.Id, "user": user.Id, "min": 1, }) require.NoError(t, err) cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "CPU", "system": system1.Id, "user": user.Id, "value": 80, "min": 1, }) require.NoError(t, err) memoryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Memory", "system": system2.Id, "user": user.Id, "value": 90, "min": 1, }) require.NoError(t, err) cache := alerts.NewAlertsCache(hub) cache.PopulateFromDB(false) statusAlerts := cache.GetAlertsByName(system1.Id, "Status") require.Len(t, statusAlerts, 1) assert.Equal(t, statusAlert.Id, statusAlerts[0].Id) nonStatusAlerts := cache.GetAlertsExcludingNames(system1.Id, "Status") require.Len(t, nonStatusAlerts, 1) assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id) system2Alerts := cache.GetSystemAlerts(system2.Id) require.Len(t, system2Alerts, 1) assert.Equal(t, memoryAlert.Id, system2Alerts[0].Id) } func TestSystemAlertsCacheLazyLoadUpdateAndDelete(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] statusAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemRecord.Id, "user": user.Id, "min": 1, }) require.NoError(t, err) cache := alerts.NewAlertsCache(hub) require.Len(t, cache.GetSystemAlerts(systemRecord.Id), 1, "first lookup should lazy-load alerts for the system") cpuAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "CPU", "system": systemRecord.Id, "user": user.Id, "value": 80, "min": 1, }) require.NoError(t, err) cache.Update(cpuAlert) nonStatusAlerts := cache.GetAlertsExcludingNames(systemRecord.Id, "Status") require.Len(t, nonStatusAlerts, 1) assert.Equal(t, cpuAlert.Id, nonStatusAlerts[0].Id) cache.Delete(statusAlert) assert.Empty(t, cache.GetAlertsByName(systemRecord.Id, "Status"), "deleted alerts should be removed from the in-memory cache") } func TestSystemAlertsCacheRefreshReturnsLatestCopy(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) system := systems[0] alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": 1, "triggered": false, }) require.NoError(t, err) cache := alerts.NewAlertsCache(hub) snapshot := cache.GetSystemAlerts(system.Id)[0] assert.False(t, snapshot.Triggered) alert.Set("triggered", true) require.NoError(t, hub.Save(alert)) refreshed, ok := cache.Refresh(snapshot) require.True(t, ok) assert.Equal(t, snapshot.Id, refreshed.Id) assert.True(t, refreshed.Triggered, "refresh should return the updated cached value rather than the stale snapshot") require.NoError(t, hub.Delete(alert)) _, ok = cache.Refresh(snapshot) assert.False(t, ok, "refresh should report false when the cached alert no longer exists") } func TestAlertManagerCacheLifecycle(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) system := systems[0] // Create an alert alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "CPU", "system": system.Id, "user": user.Id, "value": 80, "min": 1, }) require.NoError(t, err) am := hub.AlertManager cache := am.GetSystemAlertsCache() // Verify it's in cache (it should be since CreateRecord triggers the event) assert.Len(t, cache.GetSystemAlerts(system.Id), 1) assert.Equal(t, alert.Id, cache.GetSystemAlerts(system.Id)[0].Id) assert.EqualValues(t, 80, cache.GetSystemAlerts(system.Id)[0].Value) // Update the alert through PocketBase to trigger events alert.Set("value", 85) require.NoError(t, hub.Save(alert)) // Check if updated value is reflected (or at least that it's still there) cachedAlerts := cache.GetSystemAlerts(system.Id) assert.Len(t, cachedAlerts, 1) assert.EqualValues(t, 85, cachedAlerts[0].Value) // Delete the alert through PocketBase to trigger events require.NoError(t, hub.Delete(alert)) // Verify it's removed from cache assert.Empty(t, cache.GetSystemAlerts(system.Id), "alert should be removed from cache after PocketBase delete") } // func TestAlertManagerCacheMovesAlertToNewSystemOnUpdate(t *testing.T) { // hub, user := beszelTests.GetHubWithUser(t) // defer hub.Cleanup() // systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up") // require.NoError(t, err) // system1 := systems[0] // system2 := systems[1] // alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ // "name": "CPU", // "system": system1.Id, // "user": user.Id, // "value": 80, // "min": 1, // }) // require.NoError(t, err) // am := hub.AlertManager // cache := am.GetSystemAlertsCache() // // Initially in system1 cache // assert.Len(t, cache.Get(system1.Id), 1) // assert.Empty(t, cache.Get(system2.Id)) // // Move alert to system2 // alert.Set("system", system2.Id) // require.NoError(t, hub.Save(alert)) // // DEBUG: print if it is found // // fmt.Printf("system1 alerts after update: %v\n", cache.Get(system1.Id)) // // Should be removed from system1 and present in system2 // assert.Empty(t, cache.GetType(system1.Id, "CPU"), "updated alerts should be evicted from the previous system cache") // require.Len(t, cache.Get(system2.Id), 1) // assert.Equal(t, alert.Id, cache.Get(system2.Id)[0].Id) // } ================================================ FILE: internal/alerts/alerts_disk_test.go ================================================ //go:build testing package alerts_test import ( "encoding/json" "testing" "time" "github.com/henrygd/beszel/internal/entities/system" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/tools/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestDiskAlertExtraFsMultiMinute tests that multi-minute disk alerts correctly use // historical per-minute values for extra (non-root) filesystems, not the current live snapshot. func TestDiskAlertExtraFsMultiMinute(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] // Disk alert: threshold 80%, min=2 (requires historical averaging) diskAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Disk", "system": systemRecord.Id, "user": user.Id, "value": 80, // threshold: 80% "min": 2, // 2 minutes - requires historical averaging }) require.NoError(t, err) assert.False(t, diskAlert.GetBool("triggered"), "Alert should not be triggered initially") am := hub.GetAlertManager() now := time.Now().UTC() extraFsHigh := map[string]*system.FsStats{ "/mnt/data": {DiskTotal: 1000, DiskUsed: 920}, // 92% - above threshold } // Insert 4 historical records spread over 3 minutes (same pattern as battery tests). // The oldest record must predate (now - 2min) so the alert time window is valid. recordTimes := []time.Duration{ -180 * time.Second, // 3 min ago - anchors oldest record before alert.time -90 * time.Second, -60 * time.Second, -30 * time.Second, } for _, offset := range recordTimes { stats := system.Stats{ DiskPct: 30, // root disk at 30% - below threshold ExtraFs: extraFsHigh, } statsJSON, _ := json.Marshal(stats) recordTime := now.Add(offset) record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsJSON), }) require.NoError(t, err) record.SetRaw("created", recordTime.Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) } combinedDataHigh := &system.CombinedData{ Stats: system.Stats{ DiskPct: 30, ExtraFs: extraFsHigh, }, Info: system.Info{ DiskPct: 30, }, } systemRecord.Set("updated", now) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) err = am.HandleSystemAlerts(systemRecord, combinedDataHigh) require.NoError(t, err) time.Sleep(20 * time.Millisecond) diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id}) require.NoError(t, err) assert.True(t, diskAlert.GetBool("triggered"), "Alert SHOULD be triggered when extra disk average (92%%) exceeds threshold (80%%)") // --- Resolution: extra disk drops to 50%, alert should resolve --- extraFsLow := map[string]*system.FsStats{ "/mnt/data": {DiskTotal: 1000, DiskUsed: 500}, // 50% - below threshold } newNow := now.Add(2 * time.Minute) recordTimesLow := []time.Duration{ -180 * time.Second, -90 * time.Second, -60 * time.Second, -30 * time.Second, } for _, offset := range recordTimesLow { stats := system.Stats{ DiskPct: 30, ExtraFs: extraFsLow, } statsJSON, _ := json.Marshal(stats) recordTime := newNow.Add(offset) record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{ "system": systemRecord.Id, "type": "1m", "stats": string(statsJSON), }) require.NoError(t, err) record.SetRaw("created", recordTime.Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) } combinedDataLow := &system.CombinedData{ Stats: system.Stats{ DiskPct: 30, ExtraFs: extraFsLow, }, Info: system.Info{ DiskPct: 30, }, } systemRecord.Set("updated", newNow) err = hub.SaveNoValidate(systemRecord) require.NoError(t, err) err = am.HandleSystemAlerts(systemRecord, combinedDataLow) require.NoError(t, err) time.Sleep(20 * time.Millisecond) diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id}) require.NoError(t, err) assert.False(t, diskAlert.GetBool("triggered"), "Alert should be resolved when extra disk average (50%%) drops below threshold (80%%)") } ================================================ FILE: internal/alerts/alerts_history.go ================================================ package alerts import ( "time" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" ) // On triggered alert record delete, set matching alert history record to resolved func resolveHistoryOnAlertDelete(e *core.RecordEvent) error { if !e.Record.GetBool("triggered") { return e.Next() } _ = resolveAlertHistoryRecord(e.App, e.Record.Id) return e.Next() } // On alert record update, update alert history record func updateHistoryOnAlertUpdate(e *core.RecordEvent) error { original := e.Record.Original() new := e.Record originalTriggered := original.GetBool("triggered") newTriggered := new.GetBool("triggered") // no need to update alert history if triggered state has not changed if originalTriggered == newTriggered { return e.Next() } // if new state is triggered, create new alert history record if newTriggered { _, _ = createAlertHistoryRecord(e.App, new) return e.Next() } // if new state is not triggered, check for matching alert history record and set it to resolved _ = resolveAlertHistoryRecord(e.App, new.Id) return e.Next() } // resolveAlertHistoryRecord sets the resolved field to the current time func resolveAlertHistoryRecord(app core.App, alertRecordID string) error { alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID}) if err != nil || alertHistoryRecord == nil { return err } alertHistoryRecord.Set("resolved", time.Now().UTC()) err = app.Save(alertHistoryRecord) if err != nil { app.Logger().Error("Failed to resolve alert history", "err", err) } return err } // createAlertHistoryRecord creates a new alert history record func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) { alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history") if err != nil { return nil, err } alertHistoryRecord = core.NewRecord(alertHistoryCollection) alertHistoryRecord.Set("alert_id", alertRecord.Id) alertHistoryRecord.Set("user", alertRecord.GetString("user")) alertHistoryRecord.Set("system", alertRecord.GetString("system")) alertHistoryRecord.Set("name", alertRecord.GetString("name")) alertHistoryRecord.Set("value", alertRecord.GetFloat("value")) err = app.Save(alertHistoryRecord) if err != nil { app.Logger().Error("Failed to save alert history", "err", err) } return alertHistoryRecord, err } ================================================ FILE: internal/alerts/alerts_quiet_hours_test.go ================================================ //go:build testing package alerts_test import ( "testing" "testing/synctest" "time" "github.com/henrygd/beszel/internal/alerts" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/stretchr/testify/assert" ) func TestAlertSilencedOneTime(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Create an alert alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "CPU", "system": system.Id, "user": user.Id, "value": 80, "min": 1, }) assert.NoError(t, err) // Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour) now := time.Now().UTC() startTime := now.Add(-1 * time.Hour) endTime := now.Add(1 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "one-time", "start": startTime, "end": endTime, }) assert.NoError(t, err) // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Test that alert is silenced silenced := am.IsNotificationSilenced(user.Id, system.Id) assert.True(t, silenced, "Alert should be silenced during active one-time window") // Create a window that has already ended pastStart := now.Add(-3 * time.Hour) pastEnd := now.Add(-2 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "one-time", "start": pastStart, "end": pastEnd, }) assert.NoError(t, err) // Should still be silenced because of the first window silenced = am.IsNotificationSilenced(user.Id, system.Id) assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)") // Clear all windows and create a future window _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() assert.NoError(t, err) futureStart := now.Add(2 * time.Hour) futureEnd := now.Add(3 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "one-time", "start": futureStart, "end": futureEnd, }) assert.NoError(t, err) // Alert should NOT be silenced (window hasn't started yet) silenced = am.IsNotificationSilenced(user.Id, system.Id) assert.False(t, silenced, "Alert should not be silenced (window hasn't started)") _ = alert } func TestAlertSilencedDaily(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Get current hour and create a window that includes current time now := time.Now().UTC() currentHour := now.Hour() currentMin := now.Minute() // Create a window from 1 hour ago to 1 hour from now startHour := (currentHour - 1 + 24) % 24 endHour := (currentHour + 1) % 24 // Create times with just the hours/minutes we want (date doesn't matter for daily) startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC) endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "daily", "start": startTime, "end": endTime, }) assert.NoError(t, err) // Alert should be silenced (current time is within the daily window) silenced := am.IsNotificationSilenced(user.Id, system.Id) assert.True(t, silenced, "Alert should be silenced during active daily window") // Clear windows and create one that doesn't include current time _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() assert.NoError(t, err) // Create a window from 6-12 hours from now futureStartHour := (currentHour + 6) % 24 futureEndHour := (currentHour + 12) % 24 startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC) endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "daily", "start": startTime, "end": endTime, }) assert.NoError(t, err) // Alert should NOT be silenced silenced = am.IsNotificationSilenced(user.Id, system.Id) assert.False(t, silenced, "Alert should not be silenced (outside daily window)") } func TestAlertSilencedDailyMidnightCrossing(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Create a window that crosses midnight: 22:00 - 02:00 startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC) endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "daily", "start": startTime, "end": endTime, }) assert.NoError(t, err) // Test with a time at 23:00 (should be silenced) // We can't control the actual current time, but we can verify the logic // by checking if the window was created correctly windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{ "user": user.Id, "system": system.Id, }) assert.NoError(t, err) assert.Len(t, windows, 1, "Should have created 1 window") window := windows[0] assert.Equal(t, "daily", window.GetString("type")) assert.Equal(t, 22, window.GetDateTime("start").Time().Hour()) assert.Equal(t, 2, window.GetDateTime("end").Time().Hour()) } func TestAlertSilencedGlobal(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create multiple systems systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up") assert.NoError(t, err) // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Create a global quiet hours window (no system specified) now := time.Now().UTC() startTime := now.Add(-1 * time.Hour) endTime := now.Add(1 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "type": "one-time", "start": startTime, "end": endTime, // system field is empty/null for global windows }) assert.NoError(t, err) // All systems should be silenced for _, system := range systems { silenced := am.IsNotificationSilenced(user.Id, system.Id) assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id) } // Even with a systemID that doesn't exist, should be silenced silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system") assert.True(t, silenced, "Alert should be silenced for any system (global window)") } func TestAlertSilencedSystemSpecific(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create multiple systems systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up") assert.NoError(t, err) system1 := systems[0] system2 := systems[1] // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Create a system-specific quiet hours window for system1 only now := time.Now().UTC() startTime := now.Add(-1 * time.Hour) endTime := now.Add(1 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system1.Id, "type": "one-time", "start": startTime, "end": endTime, }) assert.NoError(t, err) // System1 should be silenced silenced := am.IsNotificationSilenced(user.Id, system1.Id) assert.True(t, silenced, "Alert should be silenced for system1") // System2 should NOT be silenced silenced = am.IsNotificationSilenced(user.Id, system2.Id) assert.False(t, silenced, "Alert should not be silenced for system2") } func TestAlertSilencedMultiUser(t *testing.T) { hub, _ := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create two users user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password") assert.NoError(t, err) user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password") assert.NoError(t, err) // Create a system accessible to both users system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "shared-system", "users": []string{user1.Id, user2.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Create a quiet hours window for user1 only now := time.Now().UTC() startTime := now.Add(-1 * time.Hour) endTime := now.Add(1 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user1.Id, "system": system.Id, "type": "one-time", "start": startTime, "end": endTime, }) assert.NoError(t, err) // User1 should be silenced silenced := am.IsNotificationSilenced(user1.Id, system.Id) assert.True(t, silenced, "Alert should be silenced for user1") // User2 should NOT be silenced silenced = am.IsNotificationSilenced(user2.Id, system.Id) assert.False(t, silenced, "Alert should not be silenced for user2") } func TestAlertSilencedWithActualAlert(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Create a status alert _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": 1, }) assert.NoError(t, err) // Create user settings with email userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id}) if err != nil || userSettings == nil { userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{ "user": user.Id, "settings": map[string]any{ "emails": []string{"test@example.com"}, }, }) assert.NoError(t, err) } // Create a quiet hours window now := time.Now().UTC() startTime := now.Add(-1 * time.Hour) endTime := now.Add(1 * time.Hour) _, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{ "user": user.Id, "system": system.Id, "type": "one-time", "start": startTime, "end": endTime, }) assert.NoError(t, err) // Get initial email count initialEmailCount := hub.TestMailer.TotalSend() // Trigger an alert by setting system to down system.Set("status", "down") err = hub.SaveNoValidate(system) assert.NoError(t, err) // Wait for the alert to be processed (1 minute + buffer) time.Sleep(time.Second * 75) synctest.Wait() // Check that no email was sent (because alert is silenced) finalEmailCount := hub.TestMailer.TotalSend() assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced") // Clear quiet hours windows _, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute() assert.NoError(t, err) // Reset system to up, then down again system.Set("status", "up") err = hub.SaveNoValidate(system) assert.NoError(t, err) time.Sleep(100 * time.Millisecond) system.Set("status", "down") err = hub.SaveNoValidate(system) assert.NoError(t, err) // Wait for the alert to be processed time.Sleep(time.Second * 75) synctest.Wait() // Now an email should be sent newEmailCount := hub.TestMailer.TotalSend() assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced") }) } func TestAlertSilencedNoWindows(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Get alert manager am := alerts.NewAlertManager(hub) defer am.Stop() // Without any quiet hours windows, alert should NOT be silenced silenced := am.IsNotificationSilenced(user.Id, system.Id) assert.False(t, silenced, "Alert should not be silenced when no windows exist") } ================================================ FILE: internal/alerts/alerts_smart.go ================================================ package alerts import ( "fmt" "strings" "github.com/pocketbase/pocketbase/core" ) // handleSmartDeviceAlert sends alerts when a SMART device state worsens into WARNING/FAILED. // This is automatic and does not require user opt-in. func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error { oldState := e.Record.Original().GetString("state") newState := e.Record.GetString("state") if !shouldSendSmartDeviceAlert(oldState, newState) { return e.Next() } systemID := e.Record.GetString("system") if systemID == "" { return e.Next() } // Fetch the system record to get the name and users systemRecord, err := e.App.FindRecordById("systems", systemID) if err != nil { e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID) return e.Next() } systemName := systemRecord.GetString("name") deviceName := e.Record.GetString("name") model := e.Record.GetString("model") statusLabel := smartStateLabel(newState) // Build alert message title := fmt.Sprintf("SMART %s on %s: %s %s", statusLabel, systemName, deviceName, smartStateEmoji(newState)) var message string if model != "" { message = fmt.Sprintf("Disk %s (%s) SMART status changed to %s", deviceName, model, newState) } else { message = fmt.Sprintf("Disk %s SMART status changed to %s", deviceName, newState) } // Get users associated with the system userIDs := systemRecord.GetStringSlice("users") if len(userIDs) == 0 { return e.Next() } // Send alert to each user for _, userID := range userIDs { if err := am.SendAlert(AlertMessageData{ UserID: userID, SystemID: systemID, Title: title, Message: message, Link: am.hub.MakeLink("system", systemID), LinkText: "View " + systemName, }); err != nil { e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID) } } return e.Next() } func shouldSendSmartDeviceAlert(oldState, newState string) bool { oldSeverity := smartStateSeverity(oldState) newSeverity := smartStateSeverity(newState) // Ignore unknown states and recoveries; only alert on worsening transitions // from known-good/degraded states into WARNING/FAILED. return oldSeverity >= 1 && newSeverity > oldSeverity } func smartStateSeverity(state string) int { switch state { case "PASSED": return 1 case "WARNING": return 2 case "FAILED": return 3 default: return 0 } } func smartStateEmoji(state string) string { switch state { case "WARNING": return "\U0001F7E0" default: return "\U0001F534" } } func smartStateLabel(state string) string { switch state { case "FAILED": return "failure" default: return strings.ToLower(state) } } ================================================ FILE: internal/alerts/alerts_smart_test.go ================================================ //go:build testing package alerts_test import ( "testing" "time" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/stretchr/testify/assert" ) func TestSmartDeviceAlert(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system for the user system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) // Create a smart_device with state PASSED smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/sda", "model": "Samsung SSD 970 EVO", "state": "PASSED", }) assert.NoError(t, err) // Verify no emails sent initially assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially") // Re-fetch the record so PocketBase can properly track original values smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) // Update the smart device state to FAILED smartDevice.Set("state", "FAILED") err = hub.Save(smartDevice) assert.NoError(t, err) // Wait for the alert to be processed time.Sleep(50 * time.Millisecond) // Verify that an email was sent assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED") // Check the email content lastMessage := hub.TestMailer.LastMessage() assert.Contains(t, lastMessage.Subject, "SMART failure on test-system") assert.Contains(t, lastMessage.Subject, "/dev/sda") assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO") assert.Contains(t, lastMessage.Text, "FAILED") } func TestSmartDeviceAlertPassedToWarning(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/mmcblk0", "model": "eMMC", "state": "PASSED", }) assert.NoError(t, err) smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) smartDevice.Set("state", "WARNING") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to WARNING") lastMessage := hub.TestMailer.LastMessage() assert.Contains(t, lastMessage.Subject, "SMART warning on test-system") assert.Contains(t, lastMessage.Text, "WARNING") } func TestSmartDeviceAlertWarningToFailed(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/mmcblk0", "model": "eMMC", "state": "WARNING", }) assert.NoError(t, err) smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) smartDevice.Set("state", "FAILED") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed from WARNING to FAILED") lastMessage := hub.TestMailer.LastMessage() assert.Contains(t, lastMessage.Subject, "SMART failure on test-system") assert.Contains(t, lastMessage.Text, "FAILED") } func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system for the user system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) // Create a smart_device with state UNKNOWN smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/sda", "model": "Samsung SSD 970 EVO", "state": "UNKNOWN", }) assert.NoError(t, err) // Re-fetch the record so PocketBase can properly track original values smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) // Update the state from UNKNOWN to FAILED - should NOT trigger alert. // We only alert from known healthy/degraded states. smartDevice.Set("state", "FAILED") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) // Verify no email was sent (only PASSED -> FAILED triggers alert) assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED") // Re-fetch the record again smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) // Update state from FAILED to PASSED - should NOT trigger alert smartDevice.Set("state", "PASSED") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) // Verify no email was sent assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED") } func TestSmartDeviceAlertMultipleUsers(t *testing.T) { hub, user1 := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a second user user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password") assert.NoError(t, err) // Create user settings for the second user _, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{ "user": user2.Id, "settings": `{"emails":["test2@example.com"],"webhooks":[]}`, }) assert.NoError(t, err) // Create a system with both users system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "shared-system", "users": []string{user1.Id, user2.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) // Create a smart_device with state PASSED smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/nvme0n1", "model": "WD Black SN850", "state": "PASSED", }) assert.NoError(t, err) // Re-fetch the record so PocketBase can properly track original values smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) // Update the smart device state to FAILED smartDevice.Set("state", "FAILED") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) // Verify that two emails were sent (one for each user) assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users") } func TestSmartDeviceAlertWithoutModel(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system for the user system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) assert.NoError(t, err) // Create a smart_device with state PASSED but no model smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{ "system": system.Id, "name": "/dev/sdb", "state": "PASSED", }) assert.NoError(t, err) // Re-fetch the record so PocketBase can properly track original values smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id) assert.NoError(t, err) // Update the smart device state to FAILED smartDevice.Set("state", "FAILED") err = hub.Save(smartDevice) assert.NoError(t, err) time.Sleep(50 * time.Millisecond) // Verify that an email was sent assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent") // Check that the email doesn't have empty parentheses for missing model lastMessage := hub.TestMailer.LastMessage() assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model") assert.Contains(t, lastMessage.Text, "/dev/sdb") } ================================================ FILE: internal/alerts/alerts_status.go ================================================ package alerts import ( "fmt" "strings" "time" "github.com/pocketbase/pocketbase/core" ) type alertInfo struct { systemName string alertData CachedAlertData expireTime time.Time timer *time.Timer } // Stop cancels all pending status alert timers. func (am *AlertManager) Stop() { am.stopOnce.Do(func() { am.pendingAlerts.Range(func(key, value any) bool { info := value.(*alertInfo) if info.timer != nil { info.timer.Stop() } am.pendingAlerts.Delete(key) return true }) }) } // HandleStatusAlerts manages the logic when system status changes. func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.Record) error { if newStatus != "up" && newStatus != "down" { return nil } alerts := am.alertsCache.GetAlertsByName(systemRecord.Id, "Status") if len(alerts) == 0 { return nil } systemName := systemRecord.GetString("name") if newStatus == "down" { am.handleSystemDown(systemName, alerts) } else { am.handleSystemUp(systemName, alerts) } return nil } // handleSystemDown manages the logic when a system status changes to "down". It schedules pending alerts for each alert record. func (am *AlertManager) handleSystemDown(systemName string, alerts []CachedAlertData) { for _, alertData := range alerts { min := max(1, int(alertData.Min)) am.schedulePendingStatusAlert(systemName, alertData, time.Duration(min)*time.Minute) } } // schedulePendingStatusAlert sets up a timer to send a "down" alert after the specified delay if the system is still down. // It returns true if the alert was scheduled, or false if an alert was already pending for the given alert record. func (am *AlertManager) schedulePendingStatusAlert(systemName string, alertData CachedAlertData, delay time.Duration) bool { alert := &alertInfo{ systemName: systemName, alertData: alertData, expireTime: time.Now().Add(delay), } storedAlert, loaded := am.pendingAlerts.LoadOrStore(alertData.Id, alert) if loaded { return false } stored := storedAlert.(*alertInfo) stored.timer = time.AfterFunc(time.Until(stored.expireTime), func() { am.processPendingAlert(alertData.Id) }) return true } // handleSystemUp manages the logic when a system status changes to "up". // It cancels any pending alerts and sends "up" alerts. func (am *AlertManager) handleSystemUp(systemName string, alerts []CachedAlertData) { for _, alertData := range alerts { // If alert exists for record, delete and continue (down alert not sent) if am.cancelPendingAlert(alertData.Id) { continue } if !alertData.Triggered { continue } if err := am.sendStatusAlert("up", systemName, alertData); err != nil { am.hub.Logger().Error("Failed to send alert", "err", err) } } } // cancelPendingAlert stops the timer and removes the pending alert for the given alert ID. Returns true if a pending alert was found and cancelled. func (am *AlertManager) cancelPendingAlert(alertID string) bool { value, loaded := am.pendingAlerts.LoadAndDelete(alertID) if !loaded { return false } info := value.(*alertInfo) if info.timer != nil { info.timer.Stop() } return true } // processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down. func (am *AlertManager) processPendingAlert(alertID string) { value, loaded := am.pendingAlerts.LoadAndDelete(alertID) if !loaded { return } info := value.(*alertInfo) refreshedAlertData, ok := am.alertsCache.Refresh(info.alertData) if !ok || refreshedAlertData.Triggered { return } if err := am.sendStatusAlert("down", info.systemName, refreshedAlertData); err != nil { am.hub.Logger().Error("Failed to send alert", "err", err) } } // sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records. func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertData CachedAlertData) error { // Update trigger state for alert record before sending alert triggered := alertStatus == "down" if err := am.setAlertTriggered(alertData, triggered); err != nil { return err } var emoji string if alertStatus == "up" { emoji = "\u2705" // Green checkmark emoji } else { emoji = "\U0001F534" // Red alert emoji } title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji) message := strings.TrimSuffix(title, emoji) // Get system ID for the link systemID := alertData.SystemID return am.SendAlert(AlertMessageData{ UserID: alertData.UserID, SystemID: systemID, Title: title, Message: message, Link: am.hub.MakeLink("system", systemID), LinkText: "View " + systemName, }) } // resolveStatusAlerts resolves any triggered status alerts that weren't resolved // when system came up (https://github.com/henrygd/beszel/issues/1052). func resolveStatusAlerts(app core.App) error { db := app.DB() // Find all active status alerts where the system is actually up var alertIds []string err := db.NewQuery(` SELECT a.id FROM alerts a JOIN systems s ON a.system = s.id WHERE a.name = 'Status' AND a.triggered = true AND s.status = 'up' `).Column(&alertIds) if err != nil { return err } // resolve all matching alert records for _, alertId := range alertIds { alert, err := app.FindRecordById("alerts", alertId) if err != nil { return err } alert.Set("triggered", false) err = app.Save(alert) if err != nil { return err } } return nil } // restorePendingStatusAlerts re-queues untriggered status alerts for systems that // are still down after a hub restart. This rebuilds the lost in-memory timer state. func (am *AlertManager) restorePendingStatusAlerts() error { type pendingStatusAlert struct { AlertID string `db:"alert_id"` SystemID string `db:"system_id"` SystemName string `db:"system_name"` } var pending []pendingStatusAlert err := am.hub.DB().NewQuery(` SELECT a.id AS alert_id, a.system AS system_id, s.name AS system_name FROM alerts a JOIN systems s ON a.system = s.id WHERE a.name = 'Status' AND a.triggered = false AND s.status = 'down' `).All(&pending) if err != nil { return err } // Make sure cache is populated before trying to restore pending alerts _ = am.alertsCache.PopulateFromDB(false) for _, item := range pending { alertData, ok := am.alertsCache.GetAlert(item.SystemID, item.AlertID) if !ok { continue } min := max(1, int(alertData.Min)) am.schedulePendingStatusAlert(item.SystemName, alertData, time.Duration(min)*time.Minute) } return nil } ================================================ FILE: internal/alerts/alerts_status_test.go ================================================ //go:build testing package alerts_test import ( "testing" "testing/synctest" "time" "github.com/henrygd/beszel/internal/alerts" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) { t.Helper() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": userID}) require.NoError(t, err) userSettings.Set("settings", map[string]any{ "emails": []string{email}, "webhooks": []string{}, }) require.NoError(t, hub.Save(userSettings)) } func TestStatusAlerts(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused") assert.NoError(t, err) var alerts []*core.Record for i, system := range systems { alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": i + 1, }) assert.NoError(t, err) alerts = append(alerts, alert) } time.Sleep(10 * time.Millisecond) for _, alert := range alerts { assert.False(t, alert.GetBool("triggered"), "Alert should not be triggered immediately") } if hub.TestMailer.TotalSend() != 0 { assert.Zero(t, hub.TestMailer.TotalSend(), "Expected 0 messages, got %d", hub.TestMailer.TotalSend()) } for _, system := range systems { assert.EqualValues(t, "paused", system.GetString("status"), "System should be paused") } for _, system := range systems { system.Set("status", "up") err = hub.SaveNoValidate(system) assert.NoError(t, err) } time.Sleep(time.Second) assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map") for _, system := range systems { system.Set("status", "down") err = hub.SaveNoValidate(system) assert.NoError(t, err) } // after 30 seconds, should have 4 alerts in the pendingAlerts map, no triggered alerts time.Sleep(time.Second * 30) assert.EqualValues(t, 4, hub.GetPendingAlertsCount(), "should have 4 alerts in the pendingAlerts map") triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true}) assert.NoError(t, err) assert.EqualValues(t, 0, triggeredCount, "should have 0 alert triggered") assert.EqualValues(t, 0, hub.TestMailer.TotalSend(), "should have 0 messages sent") // after 1:30 seconds, should have 1 triggered alert and 3 pending alerts time.Sleep(time.Second * 60) assert.EqualValues(t, 3, hub.GetPendingAlertsCount(), "should have 3 alerts in the pendingAlerts map") triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true}) assert.NoError(t, err) assert.EqualValues(t, 1, triggeredCount, "should have 1 alert triggered") assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 messages sent") // after 2:30 seconds, should have 2 triggered alerts and 2 pending alerts time.Sleep(time.Second * 60) assert.EqualValues(t, 2, hub.GetPendingAlertsCount(), "should have 2 alerts in the pendingAlerts map") triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true}) assert.NoError(t, err) assert.EqualValues(t, 2, triggeredCount, "should have 2 alert triggered") assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 messages sent") // now we will bring the remaning systems back up for _, system := range systems { system.Set("status", "up") err = hub.SaveNoValidate(system) assert.NoError(t, err) } time.Sleep(time.Second) // should have 0 alerts in the pendingAlerts map and 0 alerts triggered assert.EqualValues(t, 0, hub.GetPendingAlertsCount(), "should have 0 alerts in the pendingAlerts map") triggeredCount, err = hub.CountRecords("alerts", dbx.HashExp{"triggered": true}) assert.NoError(t, err) assert.Zero(t, triggeredCount, "should have 0 alert triggered") // 4 messages sent, 2 down alerts and 2 up alerts for first 2 systems assert.EqualValues(t, 4, hub.TestMailer.TotalSend(), "should have 4 messages sent") }) } func TestStatusAlertRecoveryBeforeDeadline(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Ensure user settings have an email userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) hub.Save(userSettings) // Initial email count initialEmailCount := hub.TestMailer.TotalSend() systemCollection, _ := hub.FindCollectionByNameOrId("systems") system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) hub.Save(system) alertCollection, _ := hub.FindCollectionByNameOrId("alerts") alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 1) hub.Save(alert) am := hub.AlertManager // 1. System goes down am.HandleStatusAlerts("down", system) assert.Equal(t, 1, am.GetPendingAlertsCount(), "Alert should be scheduled") // 2. System goes up BEFORE delay expires // Triggering HandleStatusAlerts("up") SHOULD NOT send an alert. am.HandleStatusAlerts("up", system) assert.Equal(t, 0, am.GetPendingAlertsCount(), "Alert should be canceled if system recovers before delay expires") // Verify that NO email was sent. assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "Recovery notification should not be sent if system never went down") } func TestStatusAlertNormalRecovery(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Ensure user settings have an email userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) hub.Save(userSettings) systemCollection, _ := hub.FindCollectionByNameOrId("systems") system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) hub.Save(system) alertCollection, _ := hub.FindCollectionByNameOrId("alerts") alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", true) // System was confirmed DOWN hub.Save(alert) am := hub.AlertManager initialEmailCount := hub.TestMailer.TotalSend() // System goes up am.HandleStatusAlerts("up", system) // Verify that an email WAS sent (normal recovery). assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "Recovery notification should be sent if system was triggered as down") } func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) systemCollection, err := hub.FindCollectionByNameOrId("systems") require.NoError(t, err) system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) require.NoError(t, hub.Save(system)) alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 1) require.NoError(t, hub.Save(alert)) initialEmailCount := hub.TestMailer.TotalSend() am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.HandleStatusAlerts("down", system)) assert.Equal(t, 1, am.GetPendingAlertsCount(), "down transition should register a pending alert immediately") require.NoError(t, am.HandleStatusAlerts("up", system)) assert.Zero(t, am.GetPendingAlertsCount(), "recovery should cancel the pending down alert") assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "recovery notification should not be sent before a down alert triggers") alertRecord, err := hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.False(t, alertRecord.GetBool("triggered"), "alert should remain untriggered when downtime never matured") } func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) systemCollection, err := hub.FindCollectionByNameOrId("systems") require.NoError(t, err) system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) require.NoError(t, hub.Save(system)) alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 1) require.NoError(t, hub.Save(alert)) initialEmailCount := hub.TestMailer.TotalSend() am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.HandleStatusAlerts("down", system)) assert.Equal(t, 1, am.GetPendingAlertsCount(), "down transition should register a pending alert immediately") require.True(t, am.ResetPendingAlertTimer(alert.Id, 25*time.Millisecond), "test should shorten the pending alert timer") time.Sleep(10 * time.Millisecond) require.NoError(t, am.HandleStatusAlerts("up", system)) assert.Zero(t, am.GetPendingAlertsCount(), "recovery should remove the pending alert before the timer callback runs") time.Sleep(40 * time.Millisecond) assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "timer callback should not deliver after recovery cancels the pending alert") alertRecord, err := hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.False(t, alertRecord.GetBool("triggered"), "alert should remain untriggered when cancellation wins the timer race") time.Sleep(time.Minute) synctest.Wait() }) } func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) systemCollection, err := hub.FindCollectionByNameOrId("systems") require.NoError(t, err) system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) require.NoError(t, hub.Save(system)) alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 1) require.NoError(t, hub.Save(alert)) initialEmailCount := hub.TestMailer.TotalSend() am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.HandleStatusAlerts("down", system)) assert.Equal(t, 1, am.GetPendingAlertsCount(), "alert should be pending after system goes down") // Expire the pending alert and process it am.ForceExpirePendingAlerts() processed, err := am.ProcessPendingAlerts() require.NoError(t, err) assert.Len(t, processed, 1, "one alert should have been processed") assert.Equal(t, 0, am.GetPendingAlertsCount(), "pending alert should be consumed after processing") // Verify down email was sent assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "down notification should be sent after delay expires") // Verify triggered flag is set in the DB alertRecord, err := hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.True(t, alertRecord.GetBool("triggered"), "alert should be marked triggered after downtime matures") } func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user1 := beszelTests.GetHubWithUser(t) defer hub.Cleanup() setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password") require.NoError(t, err) _, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{ "user": user2.Id, "settings": map[string]any{ "emails": []string{"user2@example.com"}, "webhooks": []string{}, }, }) require.NoError(t, err) system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "shared-system", "users": []string{user1.Id, user2.Id}, "host": "127.0.0.1", }) require.NoError(t, err) system.Set("status", "up") require.NoError(t, hub.SaveNoValidate(system)) alertUser1, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user1.Id, "min": 1, }) require.NoError(t, err) alertUser2, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user2.Id, "min": 2, }) require.NoError(t, err) time.Sleep(10 * time.Millisecond) system.Set("status", "down") require.NoError(t, hub.SaveNoValidate(system)) assert.Equal(t, 2, hub.GetPendingAlertsCount(), "both user alerts should be pending after the system goes down") time.Sleep(59 * time.Second) synctest.Wait() assert.Zero(t, hub.TestMailer.TotalSend(), "no messages should be sent before the earliest alert minute elapses") time.Sleep(2 * time.Second) synctest.Wait() messages := hub.TestMailer.Messages() require.Len(t, messages, 1, "only the first user's alert should send after one minute") require.Len(t, messages[0].To, 1) assert.Equal(t, "user1@example.com", messages[0].To[0].Address) assert.Contains(t, messages[0].Subject, "Connection to shared-system is down") assert.Equal(t, 1, hub.GetPendingAlertsCount(), "the later user alert should still be pending") time.Sleep(58 * time.Second) synctest.Wait() assert.Equal(t, 1, hub.TestMailer.TotalSend(), "the second user's alert should still be waiting before two minutes") time.Sleep(2 * time.Second) synctest.Wait() messages = hub.TestMailer.Messages() require.Len(t, messages, 2, "both users should eventually receive their own status alert") require.Len(t, messages[1].To, 1) assert.Equal(t, "user2@example.com", messages[1].To[0].Address) assert.Contains(t, messages[1].Subject, "Connection to shared-system is down") assert.Zero(t, hub.GetPendingAlertsCount(), "all pending alerts should be consumed after both timers fire") alertUser1, err = hub.FindRecordById("alerts", alertUser1.Id) require.NoError(t, err) assert.True(t, alertUser1.GetBool("triggered"), "user1 alert should be marked triggered after delivery") alertUser2, err = hub.FindRecordById("alerts", alertUser2.Id) require.NoError(t, err) assert.True(t, alertUser2.GetBool("triggered"), "user2 alert should be marked triggered after delivery") }) } func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user1 := beszelTests.GetHubWithUser(t) defer hub.Cleanup() setStatusAlertEmail(t, hub, user1.Id, "user1@example.com") user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password") require.NoError(t, err) _, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{ "user": user2.Id, "settings": map[string]any{ "emails": []string{"user2@example.com"}, "webhooks": []string{}, }, }) require.NoError(t, err) system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "shared-system", "users": []string{user1.Id, user2.Id}, "host": "127.0.0.1", }) require.NoError(t, err) system.Set("status", "up") require.NoError(t, hub.SaveNoValidate(system)) alertUser1, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user1.Id, "min": 1, }) require.NoError(t, err) alertUser2, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user2.Id, "min": 2, }) require.NoError(t, err) time.Sleep(10 * time.Millisecond) system.Set("status", "down") require.NoError(t, hub.SaveNoValidate(system)) time.Sleep(61 * time.Second) synctest.Wait() messages := hub.TestMailer.Messages() require.Len(t, messages, 1, "the first user's down alert should send before recovery") require.Len(t, messages[0].To, 1) assert.Equal(t, "user1@example.com", messages[0].To[0].Address) assert.Contains(t, messages[0].Subject, "Connection to shared-system is down") assert.Equal(t, 1, hub.GetPendingAlertsCount(), "the second user's alert should still be pending") system.Set("status", "up") require.NoError(t, hub.SaveNoValidate(system)) time.Sleep(time.Second) synctest.Wait() messages = hub.TestMailer.Messages() require.Len(t, messages, 2, "recovery should notify only the user whose down alert had already triggered") for _, message := range messages { require.Len(t, message.To, 1) assert.Equal(t, "user1@example.com", message.To[0].Address) } assert.Contains(t, messages[1].Subject, "Connection to shared-system is up") assert.Zero(t, hub.GetPendingAlertsCount(), "recovery should cancel the later user's pending alert") time.Sleep(61 * time.Second) synctest.Wait() messages = hub.TestMailer.Messages() require.Len(t, messages, 2, "user2 should never receive a down alert once recovery cancels the pending timer") alertUser1, err = hub.FindRecordById("alerts", alertUser1.Id) require.NoError(t, err) assert.False(t, alertUser1.GetBool("triggered"), "user1 alert should be cleared after recovery") alertUser2, err = hub.FindRecordById("alerts", alertUser2.Id) require.NoError(t, err) assert.False(t, alertUser2.GetBool("triggered"), "user2 alert should remain untriggered because it never fired") }) } func TestStatusAlertDuplicateDownCallIsIdempotent(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) systemCollection, err := hub.FindCollectionByNameOrId("systems") require.NoError(t, err) system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) require.NoError(t, hub.Save(system)) alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 5) require.NoError(t, hub.Save(alert)) am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.HandleStatusAlerts("down", system)) require.NoError(t, am.HandleStatusAlerts("down", system)) require.NoError(t, am.HandleStatusAlerts("down", system)) assert.Equal(t, 1, am.GetPendingAlertsCount(), "repeated down calls should not schedule duplicate pending alerts") } func TestStatusAlertNoAlertRecord(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systemCollection, err := hub.FindCollectionByNameOrId("systems") require.NoError(t, err) system := core.NewRecord(systemCollection) system.Set("name", "test-system") system.Set("status", "up") system.Set("host", "127.0.0.1") system.Set("users", []string{user.Id}) require.NoError(t, hub.Save(system)) // No Status alert record created for this system initialEmailCount := hub.TestMailer.TotalSend() am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.HandleStatusAlerts("down", system)) assert.Equal(t, 0, am.GetPendingAlertsCount(), "no pending alert when no alert record exists") require.NoError(t, am.HandleStatusAlerts("up", system)) assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "no email when no alert record exists") } func TestRestorePendingStatusAlertsRequeuesDownSystemsAfterRestart(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "down") require.NoError(t, err) system := systems[0] alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", false) alert.Set("min", 1) require.NoError(t, hub.Save(alert)) initialEmailCount := hub.TestMailer.TotalSend() am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.RestorePendingStatusAlerts()) assert.Equal(t, 1, am.GetPendingAlertsCount(), "startup restore should requeue a pending down alert for a system still marked down") am.ForceExpirePendingAlerts() processed, err := am.ProcessPendingAlerts() require.NoError(t, err) assert.Len(t, processed, 1, "restored pending alert should be processable after the delay expires") assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "restored pending alert should send the down notification") alertRecord, err := hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.True(t, alertRecord.GetBool("triggered"), "restored pending alert should mark the alert as triggered once delivered") } func TestRestorePendingStatusAlertsSkipsNonDownOrAlreadyTriggeredAlerts(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systemsDown, err := beszelTests.CreateSystems(hub, 2, user.Id, "down") require.NoError(t, err) systemDownPending := systemsDown[0] systemDownTriggered := systemsDown[1] systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "up-system", "users": []string{user.Id}, "host": "127.0.0.2", "status": "up", }) require.NoError(t, err) _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemDownPending.Id, "user": user.Id, "min": 1, "triggered": false, }) require.NoError(t, err) _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemUp.Id, "user": user.Id, "min": 1, "triggered": false, }) require.NoError(t, err) _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemDownTriggered.Id, "user": user.Id, "min": 1, "triggered": true, }) require.NoError(t, err) am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.RestorePendingStatusAlerts()) assert.Equal(t, 1, am.GetPendingAlertsCount(), "only untriggered alerts for currently down systems should be restored") } func TestRestorePendingStatusAlertsIsIdempotent(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "down") require.NoError(t, err) system := systems[0] _, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": 1, "triggered": false, }) require.NoError(t, err) am := alerts.NewTestAlertManagerWithoutWorker(hub) require.NoError(t, am.RestorePendingStatusAlerts()) require.NoError(t, am.RestorePendingStatusAlerts()) assert.Equal(t, 1, am.GetPendingAlertsCount(), "restoring twice should not create duplicate pending alerts") am.ForceExpirePendingAlerts() processed, err := am.ProcessPendingAlerts() require.NoError(t, err) assert.Len(t, processed, 1, "restored alert should still be processable exactly once") assert.Zero(t, am.GetPendingAlertsCount(), "processing the restored alert should empty the pending map") } func TestResolveStatusAlertsFixesStaleTriggered(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // CreateSystems uses SaveNoValidate after initial save to bypass the // onRecordCreate hook that forces status = "pending". systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) system := systems[0] alertCollection, err := hub.FindCollectionByNameOrId("alerts") require.NoError(t, err) alert := core.NewRecord(alertCollection) alert.Set("user", user.Id) alert.Set("system", system.Id) alert.Set("name", "Status") alert.Set("triggered", true) // Stale: system is up but alert still says triggered require.NoError(t, hub.Save(alert)) // resolveStatusAlerts should clear the stale triggered flag require.NoError(t, alerts.ResolveStatusAlerts(hub)) alertRecord, err := hub.FindRecordById("alerts", alert.Id) require.NoError(t, err) assert.False(t, alertRecord.GetBool("triggered"), "stale triggered flag should be cleared when system is up") } func TestResolveStatusAlerts(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a systemUp systemUp, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", "status": "up", }) assert.NoError(t, err) systemDown, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system-2", "users": []string{user.Id}, "host": "127.0.0.2", "status": "up", }) assert.NoError(t, err) // Create a status alertUp for the system alertUp, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemUp.Id, "user": user.Id, "min": 1, }) assert.NoError(t, err) alertDown, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": systemDown.Id, "user": user.Id, "min": 1, }) assert.NoError(t, err) // Verify alert is not triggered initially assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered initially") // Set the system to 'up' (this should not trigger the alert) systemUp.Set("status", "up") err = hub.SaveNoValidate(systemUp) assert.NoError(t, err) systemDown.Set("status", "down") err = hub.SaveNoValidate(systemDown) assert.NoError(t, err) // Wait a moment for any processing time.Sleep(10 * time.Millisecond) // Verify alertUp is still not triggered after setting system to up alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id}) assert.NoError(t, err) assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered when system is up") // Manually set both alerts triggered to true alertUp.Set("triggered", true) err = hub.SaveNoValidate(alertUp) assert.NoError(t, err) alertDown.Set("triggered", true) err = hub.SaveNoValidate(alertDown) assert.NoError(t, err) // Verify we have exactly one alert with triggered true triggeredCount, err := hub.CountRecords("alerts", dbx.HashExp{"triggered": true}) assert.NoError(t, err) assert.EqualValues(t, 2, triggeredCount, "Should have exactly two alerts with triggered true") // Verify the specific alertUp is triggered alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id}) assert.NoError(t, err) assert.True(t, alertUp.GetBool("triggered"), "Alert should be triggered") // Verify we have two unresolved alert history records alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""}) assert.NoError(t, err) assert.EqualValues(t, 2, alertHistoryCount, "Should have exactly two unresolved alert history records") err = alerts.ResolveStatusAlerts(hub) assert.NoError(t, err) // Verify alertUp is not triggered after resolving alertUp, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertUp.Id}) assert.NoError(t, err) assert.False(t, alertUp.GetBool("triggered"), "Alert should not be triggered after resolving") // Verify alertDown is still triggered alertDown, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": alertDown.Id}) assert.NoError(t, err) assert.True(t, alertDown.GetBool("triggered"), "Alert should still be triggered after resolving") // Verify we have one unresolved alert history record alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""}) assert.NoError(t, err) assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record") } func TestAlertsHistoryStatus(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Create a status alertRecord for the system alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": 1, }) assert.NoError(t, err) // Verify alert is not triggered initially assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially") // Set the system to 'down' (this should trigger the alert) system.Set("status", "down") err = hub.Save(system) assert.NoError(t, err) time.Sleep(time.Second * 30) synctest.Wait() alertFresh, _ := hub.FindRecordById("alerts", alertRecord.Id) assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after 30 seconds") time.Sleep(time.Minute) synctest.Wait() // Verify alert is triggered after setting system to down alertFresh, err = hub.FindRecordById("alerts", alertRecord.Id) assert.NoError(t, err) assert.True(t, alertFresh.GetBool("triggered"), "Alert should be triggered after one minute") // Verify we have one unresolved alert history record alertHistoryCount, err := hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""}) assert.NoError(t, err) assert.EqualValues(t, 1, alertHistoryCount, "Should have exactly one unresolved alert history record") // Set the system back to 'up' (this should resolve the alert) system.Set("status", "up") err = hub.Save(system) assert.NoError(t, err) time.Sleep(time.Second) synctest.Wait() // Verify alert is not triggered after setting system back to up alertFresh, err = hub.FindRecordById("alerts", alertRecord.Id) assert.NoError(t, err) assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after system recovers") // Verify the alert history record is resolved alertHistoryCount, err = hub.CountRecords("alerts_history", dbx.HashExp{"resolved": ""}) assert.NoError(t, err) assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records") }) } func TestStatusAlertClearedBeforeSend(t *testing.T) { synctest.Test(t, func(t *testing.T) { hub, user := beszelTests.GetHubWithUser(t) defer hub.Cleanup() // Create a system systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") assert.NoError(t, err) system := systems[0] // Ensure user settings have an email userSettings, _ := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) hub.Save(userSettings) // Initial email count initialEmailCount := hub.TestMailer.TotalSend() // Create a status alertRecord for the system alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": "Status", "system": system.Id, "user": user.Id, "min": 1, }) assert.NoError(t, err) // Verify alert is not triggered initially assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially") // Set the system to 'down' (this should trigger the alert) system.Set("status", "down") err = hub.Save(system) assert.NoError(t, err) time.Sleep(time.Second * 30) synctest.Wait() // Set system back up to clear the pending alert before it triggers system.Set("status", "up") err = hub.Save(system) assert.NoError(t, err) time.Sleep(time.Minute) synctest.Wait() // Verify that we have not sent any emails since the system recovered before the alert triggered assert.Equal(t, initialEmailCount, hub.TestMailer.TotalSend(), "No email should be sent if system recovers before alert triggers") // Verify alert is not triggered after setting system back to up alertFresh, err := hub.FindRecordById("alerts", alertRecord.Id) assert.NoError(t, err) assert.False(t, alertFresh.GetBool("triggered"), "Alert should not be triggered after system recovers") // Verify that no alert history record was created since the alert never triggered alertHistoryCount, err := hub.CountRecords("alerts_history") assert.NoError(t, err) assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered") }) } ================================================ FILE: internal/alerts/alerts_system.go ================================================ package alerts import ( "encoding/json" "fmt" "strings" "time" "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/types" ) func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error { alerts := am.alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status") if len(alerts) == 0 { return nil } var validAlerts []SystemAlertData now := systemRecord.GetDateTime("updated").Time().UTC() oldestTime := now for _, alertData := range alerts { name := alertData.Name var val float64 unit := "%" switch name { case "CPU": val = data.Info.Cpu case "Memory": val = data.Info.MemPct case "Bandwidth": val = float64(data.Info.BandwidthBytes) / (1024 * 1024) unit = " MB/s" case "Disk": maxUsedPct := data.Info.DiskPct for _, fs := range data.Stats.ExtraFs { usedPct := fs.DiskUsed / fs.DiskTotal * 100 if usedPct > maxUsedPct { maxUsedPct = usedPct } } val = maxUsedPct case "Temperature": if data.Info.DashboardTemp < 1 { continue } val = data.Info.DashboardTemp unit = "°C" case "LoadAvg1": val = data.Info.LoadAvg[0] unit = "" case "LoadAvg5": val = data.Info.LoadAvg[1] unit = "" case "LoadAvg15": val = data.Info.LoadAvg[2] unit = "" case "GPU": val = data.Info.GpuPct case "Battery": if data.Stats.Battery[0] == 0 { continue } val = float64(data.Stats.Battery[0]) } triggered := alertData.Triggered threshold := alertData.Value // Battery alert has inverted logic: trigger when value is BELOW threshold lowAlert := isLowAlert(name) // CONTINUE // For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold // For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold if lowAlert { if (!triggered && val >= threshold) || (triggered && val < threshold) { continue } } else { if (!triggered && val <= threshold) || (triggered && val > threshold) { continue } } min := max(1, alertData.Min) alert := SystemAlertData{ systemRecord: systemRecord, alertData: alertData, name: name, unit: unit, val: val, threshold: threshold, triggered: triggered, min: min, } // send alert immediately if min is 1 - no need to sum up values. if min == 1 { if lowAlert { alert.triggered = val < threshold } else { alert.triggered = val > threshold } go am.sendSystemAlert(alert) continue } alert.time = now.Add(-time.Duration(min) * time.Minute) if alert.time.Before(oldestTime) { oldestTime = alert.time } validAlerts = append(validAlerts, alert) } systemStats := []struct { Stats []byte `db:"stats"` Created types.DateTime `db:"created"` }{} err := am.hub.DB(). Select("stats", "created"). From("system_stats"). Where(dbx.NewExp( "system={:system} AND type='1m' AND created > {:created}", dbx.Params{ "system": systemRecord.Id, // subtract some time to give us a bit of buffer "created": oldestTime.Add(-time.Second * 90), }, )). OrderBy("created"). All(&systemStats) if err != nil || len(systemStats) == 0 { return err } // get oldest record creation time from first record in the slice oldestRecordTime := systemStats[0].Created.Time() // log.Println("oldestRecordTime", oldestRecordTime.String()) // Filter validAlerts to keep only those with time newer than oldestRecord filteredAlerts := make([]SystemAlertData, 0, len(validAlerts)) for _, alert := range validAlerts { if alert.time.After(oldestRecordTime) { filteredAlerts = append(filteredAlerts, alert) } } validAlerts = filteredAlerts if len(validAlerts) == 0 { // log.Println("no valid alerts found") return nil } var stats SystemAlertStats // we can skip the latest systemStats record since it's the current value for i := range systemStats { stat := systemStats[i] // subtract 10 seconds to give a small time buffer systemStatsCreation := stat.Created.Time().Add(-time.Second * 10) if err := json.Unmarshal(stat.Stats, &stats); err != nil { return err } // log.Println("stats", stats) for j := range validAlerts { alert := &validAlerts[j] // reset alert val on first iteration if i == 0 { alert.val = 0 } // continue if system_stats is older than alert time range if systemStatsCreation.Before(alert.time) { continue } // add to alert value switch alert.name { case "CPU": alert.val += stats.Cpu case "Memory": alert.val += stats.Mem case "Bandwidth": alert.val += float64(stats.Bandwidth[0]+stats.Bandwidth[1]) / (1024 * 1024) case "Disk": if alert.mapSums == nil { alert.mapSums = make(map[string]float32, len(stats.ExtraFs)+1) } // add root disk if _, ok := alert.mapSums["root"]; !ok { alert.mapSums["root"] = 0.0 } alert.mapSums["root"] += float32(stats.Disk) // add extra disks from historical record for key, fs := range stats.ExtraFs { if fs.DiskTotal > 0 { if _, ok := alert.mapSums[key]; !ok { alert.mapSums[key] = 0.0 } alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100) } } case "Temperature": if alert.mapSums == nil { alert.mapSums = make(map[string]float32, len(stats.Temperatures)) } for key, temp := range stats.Temperatures { if _, ok := alert.mapSums[key]; !ok { alert.mapSums[key] = float32(0) } alert.mapSums[key] += temp } case "LoadAvg1": alert.val += stats.LoadAvg[0] case "LoadAvg5": alert.val += stats.LoadAvg[1] case "LoadAvg15": alert.val += stats.LoadAvg[2] case "GPU": if len(stats.GPU) == 0 { continue } maxUsage := 0.0 for _, gpu := range stats.GPU { if gpu.Usage > maxUsage { maxUsage = gpu.Usage } } alert.val += maxUsage case "Battery": alert.val += float64(stats.Battery[0]) default: continue } alert.count++ } } // sum up vals for each alert for _, alert := range validAlerts { switch alert.name { case "Disk": maxPct := float32(0) for key, value := range alert.mapSums { sumPct := float32(value) if sumPct > maxPct { maxPct = sumPct alert.descriptor = fmt.Sprintf("Usage of %s", key) } } alert.val = float64(maxPct / float32(alert.count)) case "Temperature": maxTemp := float32(0) for key, value := range alert.mapSums { sumTemp := float32(value) / float32(alert.count) if sumTemp > maxTemp { maxTemp = sumTemp alert.descriptor = fmt.Sprintf("Highest sensor %s", key) } } alert.val = float64(maxTemp) default: alert.val = alert.val / float64(alert.count) } minCount := float32(alert.min) / 1.2 // log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered) // log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold) // pass through alert if count is greater than or equal to minCount if float32(alert.count) >= minCount { // Battery alert has inverted logic: trigger when value is BELOW threshold lowAlert := isLowAlert(alert.name) if lowAlert { if !alert.triggered && alert.val < alert.threshold { alert.triggered = true go am.sendSystemAlert(alert) } else if alert.triggered && alert.val >= alert.threshold { alert.triggered = false go am.sendSystemAlert(alert) } } else { if !alert.triggered && alert.val > alert.threshold { alert.triggered = true go am.sendSystemAlert(alert) } else if alert.triggered && alert.val <= alert.threshold { alert.triggered = false go am.sendSystemAlert(alert) } } } } return nil } func (am *AlertManager) sendSystemAlert(alert SystemAlertData) { // log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold) systemName := alert.systemRecord.GetString("name") // change Disk to Disk usage if alert.name == "Disk" { alert.name += " usage" } // format LoadAvg5 and LoadAvg15 if after, ok := strings.CutPrefix(alert.name, "LoadAvg"); ok { alert.name = after + "m Load" } // make title alert name lowercase if not CPU or GPU titleAlertName := alert.name if titleAlertName != "CPU" && titleAlertName != "GPU" { titleAlertName = strings.ToLower(titleAlertName) } var subject string lowAlert := isLowAlert(alert.name) if alert.triggered { if lowAlert { subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName) } else { subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName) } } else { if lowAlert { subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName) } else { subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName) } } minutesLabel := "minute" if alert.min > 1 { minutesLabel += "s" } if alert.descriptor == "" { alert.descriptor = alert.name } body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel) if err := am.setAlertTriggered(alert.alertData, alert.triggered); err != nil { // app.Logger().Error("failed to save alert record", "err", err) return } am.SendAlert(AlertMessageData{ UserID: alert.alertData.UserID, SystemID: alert.systemRecord.Id, Title: subject, Message: body, Link: am.hub.MakeLink("system", alert.systemRecord.Id), LinkText: "View " + systemName, }) } func isLowAlert(name string) bool { return name == "Battery" } ================================================ FILE: internal/alerts/alerts_system_test.go ================================================ //go:build testing package alerts_test import ( "testing" "testing/synctest" "time" "github.com/henrygd/beszel/internal/entities/system" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type systemAlertValueSetter[T any] func(info *system.Info, stats *system.Stats, value T) type systemAlertTestFixture struct { hub *beszelTests.TestHub alertID string submit func(*system.CombinedData) error } func createCombinedData[T any](value T, setValue systemAlertValueSetter[T]) *system.CombinedData { var data system.CombinedData setValue(&data.Info, &data.Stats, value) return &data } func newSystemAlertTestFixture(t *testing.T, alertName string, min int, threshold float64) *systemAlertTestFixture { t.Helper() hub, user := beszelTests.GetHubWithUser(t) systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up") require.NoError(t, err) systemRecord := systems[0] sysManagerSystem, err := hub.GetSystemManager().GetSystemFromStore(systemRecord.Id) require.NoError(t, err) require.NotNil(t, sysManagerSystem) sysManagerSystem.StopUpdater() userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id}) require.NoError(t, err) userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`) require.NoError(t, hub.Save(userSettings)) alertRecord, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{ "name": alertName, "system": systemRecord.Id, "user": user.Id, "min": min, "value": threshold, }) require.NoError(t, err) assert.False(t, alertRecord.GetBool("triggered"), "Alert should not be triggered initially") alertsCache := hub.GetAlertManager().GetSystemAlertsCache() cachedAlerts := alertsCache.GetAlertsExcludingNames(systemRecord.Id, "Status") assert.Len(t, cachedAlerts, 1, "Alert should be in cache") return &systemAlertTestFixture{ hub: hub, alertID: alertRecord.Id, submit: func(data *system.CombinedData) error { _, err := sysManagerSystem.CreateRecords(data) return err }, } } func (fixture *systemAlertTestFixture) cleanup() { fixture.hub.Cleanup() } func submitValue[T any](fixture *systemAlertTestFixture, t *testing.T, value T, setValue systemAlertValueSetter[T]) { t.Helper() require.NoError(t, fixture.submit(createCombinedData(value, setValue))) } func (fixture *systemAlertTestFixture) assertTriggered(t *testing.T, triggered bool, message string) { t.Helper() alertRecord, err := fixture.hub.FindRecordById("alerts", fixture.alertID) require.NoError(t, err) assert.Equal(t, triggered, alertRecord.GetBool("triggered"), message) } func waitForSystemAlert(d time.Duration) { time.Sleep(d) synctest.Wait() } func testOneMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, setValue systemAlertValueSetter[T], triggerValue, resolveValue T) { t.Helper() synctest.Test(t, func(t *testing.T) { fixture := newSystemAlertTestFixture(t, alertName, 1, threshold) defer fixture.cleanup() submitValue(fixture, t, triggerValue, setValue) waitForSystemAlert(time.Second) fixture.assertTriggered(t, true, "Alert should be triggered") assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent") submitValue(fixture, t, resolveValue, setValue) waitForSystemAlert(time.Second) fixture.assertTriggered(t, false, "Alert should be untriggered") assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert") waitForSystemAlert(time.Minute) }) } func testMultiMinuteSystemAlert[T any](t *testing.T, alertName string, threshold float64, min int, setValue systemAlertValueSetter[T], baselineValue, triggerValue, resolveValue T) { t.Helper() synctest.Test(t, func(t *testing.T) { fixture := newSystemAlertTestFixture(t, alertName, min, threshold) defer fixture.cleanup() submitValue(fixture, t, baselineValue, setValue) waitForSystemAlert(time.Minute + time.Second) fixture.assertTriggered(t, false, "Alert should not be triggered yet") submitValue(fixture, t, triggerValue, setValue) waitForSystemAlert(time.Minute) fixture.assertTriggered(t, false, "Alert should not be triggered until the history window is full") submitValue(fixture, t, triggerValue, setValue) waitForSystemAlert(time.Second) fixture.assertTriggered(t, true, "Alert should be triggered") assert.Equal(t, 1, fixture.hub.TestMailer.TotalSend(), "An email should have been sent") submitValue(fixture, t, resolveValue, setValue) waitForSystemAlert(time.Second) fixture.assertTriggered(t, false, "Alert should be untriggered") assert.Equal(t, 2, fixture.hub.TestMailer.TotalSend(), "A second email should have been sent for untriggering the alert") }) } func setCPUAlertValue(info *system.Info, stats *system.Stats, value float64) { info.Cpu = value stats.Cpu = value } func setMemoryAlertValue(info *system.Info, stats *system.Stats, value float64) { info.MemPct = value stats.MemPct = value } func setDiskAlertValue(info *system.Info, stats *system.Stats, value float64) { info.DiskPct = value stats.DiskPct = value } func setBandwidthAlertValue(info *system.Info, stats *system.Stats, value [2]uint64) { info.BandwidthBytes = value[0] + value[1] stats.Bandwidth = value } func megabytesToBytes(mb uint64) uint64 { return mb * 1024 * 1024 } func setGPUAlertValue(info *system.Info, stats *system.Stats, value float64) { info.GpuPct = value stats.GPUData = map[string]system.GPUData{ "GPU0": {Usage: value}, } } func setTemperatureAlertValue(info *system.Info, stats *system.Stats, value float64) { info.DashboardTemp = value stats.Temperatures = map[string]float64{ "Temp0": value, } } func setLoadAvgAlertValue(info *system.Info, stats *system.Stats, value [3]float64) { info.LoadAvg = value stats.LoadAvg = value } func setBatteryAlertValue(info *system.Info, stats *system.Stats, value [2]uint8) { info.Battery = value stats.Battery = value } func TestSystemAlertsOneMin(t *testing.T) { testOneMinuteSystemAlert(t, "CPU", 50, setCPUAlertValue, 51, 49) testOneMinuteSystemAlert(t, "Memory", 50, setMemoryAlertValue, 51, 49) testOneMinuteSystemAlert(t, "Disk", 50, setDiskAlertValue, 51, 49) testOneMinuteSystemAlert(t, "Bandwidth", 50, setBandwidthAlertValue, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(25), megabytesToBytes(24)}) testOneMinuteSystemAlert(t, "GPU", 50, setGPUAlertValue, 51, 49) testOneMinuteSystemAlert(t, "Temperature", 70, setTemperatureAlertValue, 71, 69) testOneMinuteSystemAlert(t, "LoadAvg1", 4, setLoadAvgAlertValue, [3]float64{4.1, 0, 0}, [3]float64{3.9, 0, 0}) testOneMinuteSystemAlert(t, "LoadAvg5", 4, setLoadAvgAlertValue, [3]float64{0, 4.1, 0}, [3]float64{0, 3.9, 0}) testOneMinuteSystemAlert(t, "LoadAvg15", 4, setLoadAvgAlertValue, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.9}) testOneMinuteSystemAlert(t, "Battery", 20, setBatteryAlertValue, [2]uint8{19, 0}, [2]uint8{21, 0}) } func TestSystemAlertsTwoMin(t *testing.T) { testMultiMinuteSystemAlert(t, "CPU", 50, 2, setCPUAlertValue, 10, 51, 48) testMultiMinuteSystemAlert(t, "Memory", 50, 2, setMemoryAlertValue, 10, 51, 48) testMultiMinuteSystemAlert(t, "Disk", 50, 2, setDiskAlertValue, 10, 51, 48) testMultiMinuteSystemAlert(t, "Bandwidth", 50, 2, setBandwidthAlertValue, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}, [2]uint64{megabytesToBytes(26), megabytesToBytes(25)}, [2]uint64{megabytesToBytes(10), megabytesToBytes(10)}) testMultiMinuteSystemAlert(t, "GPU", 50, 2, setGPUAlertValue, 10, 51, 48) testMultiMinuteSystemAlert(t, "Temperature", 70, 2, setTemperatureAlertValue, 10, 71, 67) testMultiMinuteSystemAlert(t, "LoadAvg1", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 0}, [3]float64{4.1, 0, 0}, [3]float64{3.5, 0, 0}) testMultiMinuteSystemAlert(t, "LoadAvg5", 4, 2, setLoadAvgAlertValue, [3]float64{0, 2, 0}, [3]float64{0, 4.1, 0}, [3]float64{0, 3.5, 0}) testMultiMinuteSystemAlert(t, "LoadAvg15", 4, 2, setLoadAvgAlertValue, [3]float64{0, 0, 2}, [3]float64{0, 0, 4.1}, [3]float64{0, 0, 3.5}) testMultiMinuteSystemAlert(t, "Battery", 20, 2, setBatteryAlertValue, [2]uint8{21, 0}, [2]uint8{19, 0}, [2]uint8{25, 1}) } ================================================ FILE: internal/alerts/alerts_test.go ================================================ //go:build testing package alerts_test import ( "bytes" "encoding/json" "io" "net/http" "strings" "testing" "testing/synctest" "time" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/henrygd/beszel/internal/alerts" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" pbTests "github.com/pocketbase/pocketbase/tests" "github.com/stretchr/testify/assert" ) // marshal to json and return an io.Reader (for use in ApiScenario.Body) func jsonReader(v any) io.Reader { data, err := json.Marshal(v) if err != nil { panic(err) } return bytes.NewReader(data) } func TestUserAlertsApi(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() hub.StartHub() user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password") user1Token, _ := user1.NewAuthToken() user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password") user2Token, _ := user2.NewAuthToken() system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "system1", "users": []string{user1.Id}, "host": "127.0.0.1", }) system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "system2", "users": []string{user1.Id, user2.Id}, "host": "127.0.0.2", }) userRecords, _ := hub.CountRecords("users") assert.EqualValues(t, 2, userRecords, "all users should be created") systemRecords, _ := hub.CountRecords("systems") assert.EqualValues(t, 2, systemRecords, "all systems should be created") testAppFactory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenarios := []beszelTests.ApiScenario{ // { // Name: "GET not implemented - returns index", // Method: http.MethodGet, // URL: "/api/beszel/user-alerts", // ExpectedStatus: 200, // ExpectedContent: []string{" 1 { subcommand = os.Args[1] } // Subcommands that don't require any pflag parsing switch subcommand { case "health": err := health.Check() if err != nil { log.Fatal(err) } fmt.Print("ok") return true case "fingerprint": handleFingerprint() return true } // pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication") pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on") pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub") pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication") chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub") version := pflag.BoolP("version", "v", false, "Show version information") help := pflag.BoolP("help", "h", false, "Show this help message") // Convert old single-dash long flags to double-dash for backward compatibility flagsToConvert := []string{"key", "listen", "url", "token"} for i, arg := range os.Args { for _, flag := range flagsToConvert { singleDash := "-" + flag doubleDash := "--" + flag if arg == singleDash { os.Args[i] = doubleDash break } else if strings.HasPrefix(arg, singleDash+"=") { os.Args[i] = doubleDash + arg[len(singleDash):] break } } } pflag.Usage = func() { builder := strings.Builder{} builder.WriteString("Usage: ") builder.WriteString(os.Args[0]) builder.WriteString(" [command] [flags]\n") builder.WriteString("\nCommands:\n") builder.WriteString(" fingerprint View or reset the agent fingerprint\n") builder.WriteString(" health Check if the agent is running\n") builder.WriteString(" update Update to the latest version\n") builder.WriteString("\nFlags:\n") fmt.Print(builder.String()) pflag.PrintDefaults() } // Parse all arguments with pflag pflag.Parse() // Must run after pflag.Parse() switch { case *version: fmt.Println(beszel.AppName+"-agent", beszel.Version) return true case *help || subcommand == "help": pflag.Usage() return true case subcommand == "update": agent.Update(*chinaMirrors) return true } // Set environment variables from CLI flags (if provided) if opts.hubURL != "" { os.Setenv("HUB_URL", opts.hubURL) } if opts.token != "" { os.Setenv("TOKEN", opts.token) } return false } // loadPublicKeys loads the public keys from the command line flag, environment variable, or key file. func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) { // Try command line flag first if opts.key != "" { return agent.ParseKeys(opts.key) } // Try environment variable if key, ok := utils.GetEnv("KEY"); ok && key != "" { return agent.ParseKeys(key) } // Try key file keyFile, ok := utils.GetEnv("KEY_FILE") if !ok { return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage") } pubKey, err := os.ReadFile(keyFile) if err != nil { return nil, fmt.Errorf("failed to read key file: %w", err) } return agent.ParseKeys(string(pubKey)) } func (opts *cmdOptions) getAddress() string { return agent.GetAddress(opts.listen) } // handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset". func handleFingerprint() { subCmd := "" if len(os.Args) > 2 { subCmd = os.Args[2] } switch subCmd { case "", "view": dataDir, _ := agent.GetDataDir() fp := agent.GetFingerprint(dataDir, "", "") fmt.Println(fp) case "help", "-h", "--help": fmt.Print(fingerprintUsage()) case "reset": dataDir, err := agent.GetDataDir() if err != nil { log.Fatal(err) } if err := agent.DeleteFingerprint(dataDir); err != nil { log.Fatal(err) } fmt.Println("Fingerprint reset. A new one will be generated on next start.") default: log.Fatalf("Unknown command: %q\n\n%s", subCmd, fingerprintUsage()) } } func fingerprintUsage() string { return fmt.Sprintf("Usage: %s fingerprint [view|reset]\n\nCommands:\n view Print fingerprint (default)\n reset Reset saved fingerprint\n", os.Args[0]) } func main() { var opts cmdOptions subcommandHandled := opts.parse() if subcommandHandled { return } var serverConfig agent.ServerOptions var err error serverConfig.Keys, err = opts.loadPublicKeys() if err != nil { log.Fatal("Failed to load public keys:", err) } addr := opts.getAddress() serverConfig.Addr = addr serverConfig.Network = agent.GetNetwork(addr) a, err := agent.NewAgent() if err != nil { log.Fatal("Failed to create agent: ", err) } if err := a.Start(serverConfig); err != nil { log.Fatal("Failed to start server: ", err) } } ================================================ FILE: internal/cmd/agent/agent_test.go ================================================ package main import ( "crypto/ed25519" "os" "path/filepath" "testing" "github.com/henrygd/beszel/agent" "github.com/spf13/pflag" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) func TestGetAddress(t *testing.T) { tests := []struct { name string opts cmdOptions envVars map[string]string expected string }{ { name: "default port when no config", opts: cmdOptions{}, expected: ":45876", }, { name: "use address from flag", opts: cmdOptions{ listen: "8080", }, expected: ":8080", }, { name: "use unix socket from flag", opts: cmdOptions{ listen: "/tmp/beszel.sock", }, expected: "/tmp/beszel.sock", }, { name: "use LISTEN env var", opts: cmdOptions{}, envVars: map[string]string{ "LISTEN": "1.2.3.4:9090", }, expected: "1.2.3.4:9090", }, { name: "use legacy PORT env var", opts: cmdOptions{}, envVars: map[string]string{ "PORT": "7070", }, expected: ":7070", }, { name: "use unix socket from env var", opts: cmdOptions{ listen: "", }, envVars: map[string]string{ "LISTEN": "/tmp/beszel.sock", }, expected: "/tmp/beszel.sock", }, { name: "flag takes precedence over env vars", opts: cmdOptions{ listen: ":8080", }, envVars: map[string]string{ "LISTEN": ":9090", "PORT": "7070", }, expected: ":8080", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup environment for k, v := range tt.envVars { t.Setenv(k, v) } addr := tt.opts.getAddress() assert.Equal(t, tt.expected, addr) }) } } func TestLoadPublicKeys(t *testing.T) { // Generate a test key _, priv, err := ed25519.GenerateKey(nil) require.NoError(t, err) signer, err := ssh.NewSignerFromKey(priv) require.NoError(t, err) pubKey := ssh.MarshalAuthorizedKey(signer.PublicKey()) tests := []struct { name string opts cmdOptions envVars map[string]string setupFiles map[string][]byte wantErr bool errContains string }{ { name: "load key from flag", opts: cmdOptions{ key: string(pubKey), }, }, { name: "load key from env var", envVars: map[string]string{ "KEY": string(pubKey), }, }, { name: "load key from file", envVars: map[string]string{ "KEY_FILE": "testkey.pub", }, setupFiles: map[string][]byte{ "testkey.pub": pubKey, }, }, { name: "error when no key provided", wantErr: true, errContains: "no key provided", }, { name: "error on invalid key file", envVars: map[string]string{ "KEY_FILE": "nonexistent.pub", }, wantErr: true, errContains: "failed to read key file", }, { name: "error on invalid key data", opts: cmdOptions{ key: "invalid-key-data", }, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create a temporary directory for test files if len(tt.setupFiles) > 0 { tmpDir := t.TempDir() for name, content := range tt.setupFiles { path := filepath.Join(tmpDir, name) err := os.WriteFile(path, content, 0600) require.NoError(t, err) if tt.envVars != nil { tt.envVars["KEY_FILE"] = path } } } // Set up environment for k, v := range tt.envVars { t.Setenv(k, v) } keys, err := tt.opts.loadPublicKeys() if tt.wantErr { assert.Error(t, err) if tt.errContains != "" { assert.Contains(t, err.Error(), tt.errContains) } return } require.NoError(t, err) assert.Len(t, keys, 1) assert.Equal(t, signer.PublicKey().Type(), keys[0].Type()) }) } } func TestGetNetwork(t *testing.T) { tests := []struct { name string opts cmdOptions envVars map[string]string expected string }{ { name: "NETWORK env var", envVars: map[string]string{ "NETWORK": "tcp4", }, expected: "tcp4", }, { name: "only port", opts: cmdOptions{listen: "8080"}, expected: "tcp", }, { name: "ipv4 address", opts: cmdOptions{listen: "1.2.3.4:8080"}, expected: "tcp", }, { name: "ipv6 address", opts: cmdOptions{listen: "[2001:db8::1]:8080"}, expected: "tcp", }, { name: "unix network", opts: cmdOptions{listen: "/tmp/beszel.sock"}, expected: "unix", }, { name: "env var network", opts: cmdOptions{listen: ":8080"}, envVars: map[string]string{"NETWORK": "tcp4"}, expected: "tcp4", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup environment for k, v := range tt.envVars { t.Setenv(k, v) } network := agent.GetNetwork(tt.opts.listen) assert.Equal(t, tt.expected, network) }) } } func TestParseFlags(t *testing.T) { // Save original command line arguments and restore after test oldArgs := os.Args defer func() { os.Args = oldArgs pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError) }() tests := []struct { name string args []string expected cmdOptions }{ { name: "no flags", args: []string{"cmd"}, expected: cmdOptions{ key: "", listen: "", }, }, { name: "key flag only", args: []string{"cmd", "-key", "testkey"}, expected: cmdOptions{ key: "testkey", listen: "", }, }, { name: "key flag double dash", args: []string{"cmd", "--key", "testkey"}, expected: cmdOptions{ key: "testkey", listen: "", }, }, { name: "key flag short", args: []string{"cmd", "-k", "testkey"}, expected: cmdOptions{ key: "testkey", listen: "", }, }, { name: "addr flag only", args: []string{"cmd", "-listen", ":8080"}, expected: cmdOptions{ key: "", listen: ":8080", }, }, { name: "addr flag double dash", args: []string{"cmd", "--listen", ":8080"}, expected: cmdOptions{ key: "", listen: ":8080", }, }, { name: "addr flag short", args: []string{"cmd", "-l", ":8080"}, expected: cmdOptions{ key: "", listen: ":8080", }, }, { name: "both flags", args: []string{"cmd", "-key", "testkey", "-listen", ":8080"}, expected: cmdOptions{ key: "testkey", listen: ":8080", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset flags for each test pflag.CommandLine = pflag.NewFlagSet(tt.args[0], pflag.ExitOnError) os.Args = tt.args var opts cmdOptions opts.parse() pflag.Parse() assert.Equal(t, tt.expected, opts) }) } } ================================================ FILE: internal/cmd/hub/hub.go ================================================ package main import ( "fmt" "log" "net/http" "os" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/hub" _ "github.com/henrygd/beszel/internal/migrations" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/spf13/cobra" ) func main() { // handle health check first to prevent unneeded execution if len(os.Args) > 3 && os.Args[1] == "health" { url := os.Args[3] if err := checkHealth(url); err != nil { log.Fatal(err) } fmt.Print("ok") return } baseApp := getBaseApp() h := hub.NewHub(baseApp) if err := h.StartHub(); err != nil { log.Fatal(err) } } // getBaseApp creates a new PocketBase app with the default config func getBaseApp() *pocketbase.PocketBase { isDev := os.Getenv("ENV") == "dev" baseApp := pocketbase.NewWithConfig(pocketbase.Config{ DefaultDataDir: beszel.AppName + "_data", DefaultDev: isDev, }) baseApp.RootCmd.Version = beszel.Version baseApp.RootCmd.Use = beszel.AppName baseApp.RootCmd.Short = "" // add update command updateCmd := &cobra.Command{ Use: "update", Short: "Update " + beszel.AppName + " to the latest version", Run: hub.Update, } updateCmd.Flags().Bool("china-mirrors", false, "Use mirror (gh.beszel.dev) instead of GitHub") baseApp.RootCmd.AddCommand(updateCmd) // add health command baseApp.RootCmd.AddCommand(newHealthCmd()) // enable auto creation of migration files when making collection changes in the Admin UI migratecmd.MustRegister(baseApp, baseApp.RootCmd, migratecmd.Config{ Automigrate: isDev, Dir: "../../migrations", }) return baseApp } func newHealthCmd() *cobra.Command { var baseURL string healthCmd := &cobra.Command{ Use: "health", Short: "Check health of running hub", Run: func(cmd *cobra.Command, args []string) { if err := checkHealth(baseURL); err != nil { log.Fatal(err) } os.Exit(0) }, } healthCmd.Flags().StringVar(&baseURL, "url", "", "base URL") healthCmd.MarkFlagRequired("url") return healthCmd } // checkHealth checks the health of the hub. func checkHealth(baseURL string) error { client := &http.Client{ Timeout: time.Second * 3, } healthURL := baseURL + "/api/health" resp, err := client.Get(healthURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("%s returned status %d", healthURL, resp.StatusCode) } return nil } ================================================ FILE: internal/common/common-ssh.go ================================================ package common var ( // Allowed ssh key exchanges DefaultKeyExchanges = []string{"curve25519-sha256"} // Allowed ssh macs DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"} // Allowed ssh ciphers DefaultCiphers = []string{"chacha20-poly1305@openssh.com"} ) ================================================ FILE: internal/common/common-ws.go ================================================ package common import ( "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" ) type WebSocketAction = uint8 const ( // Request system data from agent GetData WebSocketAction = iota // Check the fingerprint of the agent CheckFingerprint // Request container logs from agent GetContainerLogs // Request container info from agent GetContainerInfo // Request SMART data from agent GetSmartData // Request detailed systemd service info from agent GetSystemdInfo // Add new actions here... ) // HubRequest defines the structure for requests sent from hub to agent. type HubRequest[T any] struct { Action WebSocketAction `cbor:"0,keyasint"` Data T `cbor:"1,keyasint,omitempty,omitzero"` Id *uint32 `cbor:"2,keyasint,omitempty"` } // AgentResponse defines the structure for responses sent from agent to hub. type AgentResponse struct { Id *uint32 `cbor:"0,keyasint,omitempty"` SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) Error string `cbor:"3,keyasint,omitempty,omitzero"` String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17) // Data is the generic response payload for new endpoints (0.18+) Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"` } type FingerprintRequest struct { Signature []byte `cbor:"0,keyasint"` NeedSysInfo bool `cbor:"1,keyasint"` // For universal token system creation } type FingerprintResponse struct { Fingerprint string `cbor:"0,keyasint"` // Optional system info for universal token system creation Hostname string `cbor:"1,keyasint,omitzero"` Port string `cbor:"2,keyasint,omitzero"` Name string `cbor:"3,keyasint,omitzero"` } type DataRequestOptions struct { CacheTimeMs uint16 `cbor:"0,keyasint"` IncludeDetails bool `cbor:"1,keyasint"` } type ContainerLogsRequest struct { ContainerID string `cbor:"0,keyasint"` } type ContainerInfoRequest struct { ContainerID string `cbor:"0,keyasint"` } type SystemdInfoRequest struct { ServiceName string `cbor:"0,keyasint"` } ================================================ FILE: internal/dockerfile_agent ================================================ FROM --platform=$BUILDPLATFORM golang:alpine AS builder WORKDIR /app COPY ../go.mod ../go.sum ./ RUN go mod download # Copy source files COPY . ./ # Build ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent RUN rm -rf /tmp/* # -------------------------- # Final image: default scratch-based agent # -------------------------- FROM scratch COPY --from=builder /agent /agent # this is so we don't need to create the /tmp directory in the scratch container COPY --from=builder /tmp /tmp # AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read) COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids # Ensure data persistence across container recreations VOLUME ["/var/lib/beszel-agent"] ENTRYPOINT ["/agent"] ================================================ FILE: internal/dockerfile_agent_alpine ================================================ FROM --platform=$BUILDPLATFORM golang:alpine AS builder WORKDIR /app COPY ../go.mod ../go.sum ./ RUN go mod download # Copy source files COPY . ./ # Build ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent RUN rm -rf /tmp/* # -------------------------- # Final image: default scratch-based agent # -------------------------- FROM alpine:3.23 COPY --from=builder /agent /agent # AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read) COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids RUN apk add --no-cache smartmontools # Ensure data persistence across container recreations VOLUME ["/var/lib/beszel-agent"] ENTRYPOINT ["/agent"] ================================================ FILE: internal/dockerfile_agent_intel ================================================ FROM --platform=$BUILDPLATFORM golang:alpine AS builder WORKDIR /app COPY ../go.mod ../go.sum ./ RUN go mod download # Copy source files COPY . ./ # Build ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent # -------------------------- # Final image # Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume # -------------------------- FROM alpine:3.23 COPY --from=builder /agent /agent RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools # Ensure data persistence across container recreations VOLUME ["/var/lib/beszel-agent"] ENTRYPOINT ["/agent"] ================================================ FILE: internal/dockerfile_agent_nvidia ================================================ FROM --platform=$BUILDPLATFORM golang:bookworm AS builder WORKDIR /app COPY ../go.mod ../go.sum ./ RUN go mod download # Copy source files COPY . ./ # Build ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent # -------------------------- # Smartmontools builder stage # -------------------------- FROM nvidia/cuda:12.2.2-base-ubuntu22.04 AS smartmontools-builder RUN apt-get update && apt-get install -y \ wget \ build-essential \ && wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \ && tar zxvf smartmontools-7.5.tar.gz \ && cd smartmontools-7.5 \ && ./configure --prefix=/usr --sysconfdir=/etc \ && make \ && make install \ && rm -rf /smartmontools-7.5* \ && apt-get remove -y wget build-essential \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* # -------------------------- # Final image: GPU-enabled agent with nvidia-smi # -------------------------- FROM nvidia/cuda:12.2.2-base-ubuntu22.04 COPY --from=builder /agent /agent # AMD GPU name lookup (used by agent on hybrid laptops when /usr/share/libdrm/amdgpu.ids is read) COPY --from=builder /app/agent/test-data/amdgpu.ids /usr/share/libdrm/amdgpu.ids # Copy smartmontools binaries and config files COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl # Ensure data persistence across container recreations VOLUME ["/var/lib/beszel-agent"] ENTRYPOINT ["/agent"] ================================================ FILE: internal/dockerfile_hub ================================================ FROM --platform=$BUILDPLATFORM golang:alpine AS builder WORKDIR /app # Download Go modules COPY ../go.mod ../go.sum ./ RUN go mod download # Copy source files COPY . ./ RUN apk add --no-cache \ unzip \ ca-certificates RUN update-ca-certificates # Build ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub # ? ------------------------- FROM scratch COPY --from=builder /beszel / COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ # Ensure data persistence across container recreations VOLUME ["/beszel_data"] EXPOSE 8090 ENTRYPOINT [ "/beszel" ] CMD ["serve", "--http=0.0.0.0:8090"] ================================================ FILE: internal/entities/container/container.go ================================================ package container import "time" // Docker container info from /containers/json type ApiInfo struct { Id string IdShort string Names []string Status string State string Image string Health struct { Status string // FailingStreak int } Ports []struct { // PrivatePort uint16 PublicPort uint16 IP string // Type string } // ImageID string // Command string // Created int64 // SizeRw int64 `json:",omitempty"` // SizeRootFs int64 `json:",omitempty"` // Labels map[string]string // HostConfig struct { // NetworkMode string `json:",omitempty"` // Annotations map[string]string `json:",omitempty"` // } // NetworkSettings *SummaryNetworkSettings // Mounts []MountPoint } // Docker container resources from /containers/{id}/stats type ApiStats struct { 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"` } // Docker system info from /info API endpoint type HostInfo struct { OperatingSystem string `json:"OperatingSystem"` KernelVersion string `json:"KernelVersion"` NCPU int `json:"NCPU"` MemTotal uint64 `json:"MemTotal"` } func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 { cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer systemDelta := s.CPUStats.SystemUsage - prevCpuSystem // Avoid division by zero and handle first run case if systemDelta == 0 || prevCpuContainer == 0 { return 0.0 } return float64(cpuDelta) / float64(systemDelta) * 100.0 } // 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 // 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"` } type CPUUsage struct { // Total CPU time consumed. // Units: nanoseconds (Linux) // Units: 100's of nanoseconds (Windows) TotalUsage uint64 `json:"total_usage"` } 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"` // private working set (Windows only) PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"` } type MemoryStatsStats struct { Cache uint64 `json:"cache,omitempty"` InactiveFile uint64 `json:"inactive_file,omitempty"` } type NetworkStats struct { // Bytes received. Windows and Linux. RxBytes uint64 `json:"rx_bytes"` // Bytes sent. Windows and Linux. TxBytes uint64 `json:"tx_bytes"` } type prevNetStats struct { Sent uint64 Recv uint64 } type DockerHealth = uint8 const ( DockerHealthNone DockerHealth = iota DockerHealthStarting DockerHealthHealthy DockerHealthUnhealthy ) var DockerHealthStrings = map[string]DockerHealth{ "none": DockerHealthNone, "starting": DockerHealthStarting, "healthy": DockerHealthHealthy, "unhealthy": DockerHealthUnhealthy, } // Docker container stats type Stats struct { Name string `json:"n" cbor:"0,keyasint"` Cpu float64 `json:"c" cbor:"1,keyasint"` Mem float64 `json:"m" cbor:"2,keyasint"` NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes] Health DockerHealth `json:"-" cbor:"5,keyasint"` Status string `json:"-" cbor:"6,keyasint"` Id string `json:"-" cbor:"7,keyasint"` Image string `json:"-" cbor:"8,keyasint"` Ports string `json:"-" cbor:"10,keyasint"` // PrevCpu [2]uint64 `json:"-"` CpuSystem uint64 `json:"-"` CpuContainer uint64 `json:"-"` PrevNet prevNetStats `json:"-"` PrevReadTime time.Time `json:"-"` } ================================================ FILE: internal/entities/smart/smart.go ================================================ package smart import ( "encoding/json" "strconv" "strings" ) // Common types type VersionInfo [2]int type SmartctlInfo struct { Version VersionInfo `json:"version"` SvnRevision string `json:"svn_revision"` PlatformInfo string `json:"platform_info"` BuildInfo string `json:"build_info"` Argv []string `json:"argv"` ExitStatus int `json:"exit_status"` } type DeviceInfo struct { Name string `json:"name"` InfoName string `json:"info_name"` Type string `json:"type"` Protocol string `json:"protocol"` } type UserCapacity struct { Blocks uint64 `json:"blocks"` Bytes uint64 `json:"bytes"` } // type LocalTime struct { // TimeT int64 `json:"time_t"` // Asctime string `json:"asctime"` // } // type WwnInfo struct { // Naa int `json:"naa"` // Oui int `json:"oui"` // ID int `json:"id"` // } // type FormFactorInfo struct { // AtaValue int `json:"ata_value"` // Name string `json:"name"` // } // type TrimInfo struct { // Supported bool `json:"supported"` // } // type AtaVersionInfo struct { // String string `json:"string"` // MajorValue int `json:"major_value"` // MinorValue int `json:"minor_value"` // } // type VersionStringInfo struct { // String string `json:"string"` // Value int `json:"value"` // } // type SpeedInfo struct { // SataValue int `json:"sata_value"` // String string `json:"string"` // UnitsPerSecond int `json:"units_per_second"` // BitsPerUnit int `json:"bits_per_unit"` // } // type InterfaceSpeedInfo struct { // Max SpeedInfo `json:"max"` // Current SpeedInfo `json:"current"` // } type SmartStatusInfo struct { Passed bool `json:"passed"` } type StatusInfo struct { Value int `json:"value"` String string `json:"string"` Passed bool `json:"passed"` } type PollingMinutes struct { Short int `json:"short"` Extended int `json:"extended"` } type CapabilitiesInfo struct { Values []int `json:"values"` ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"` OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"` OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"` SelfTestsSupported bool `json:"self_tests_supported"` ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"` SelectiveSelfTestSupported bool `json:"selective_self_test_supported"` AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"` ErrorLoggingSupported bool `json:"error_logging_supported"` GpLoggingSupported bool `json:"gp_logging_supported"` } // type AtaSmartData struct { // OfflineDataCollection OfflineDataCollectionInfo `json:"offline_data_collection"` // SelfTest SelfTestInfo `json:"self_test"` // Capabilities CapabilitiesInfo `json:"capabilities"` // } // type OfflineDataCollectionInfo struct { // Status StatusInfo `json:"status"` // CompletionSeconds int `json:"completion_seconds"` // } // type SelfTestInfo struct { // Status StatusInfo `json:"status"` // PollingMinutes PollingMinutes `json:"polling_minutes"` // } // type AtaSctCapabilities struct { // Value int `json:"value"` // ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"` // FeatureControlSupported bool `json:"feature_control_supported"` // DataTableSupported bool `json:"data_table_supported"` // } type SummaryInfo struct { Revision int `json:"revision"` Count int `json:"count"` } type AtaSmartAttributes struct { Table []AtaSmartAttribute `json:"table"` } type AtaDeviceStatistics struct { Pages []AtaDeviceStatisticsPage `json:"pages"` } type AtaDeviceStatisticsPage struct { Number uint8 `json:"number"` Table []AtaDeviceStatisticsEntry `json:"table"` } type AtaDeviceStatisticsEntry struct { Name string `json:"name"` Value *int64 `json:"value,omitempty"` } type AtaSmartAttribute struct { ID uint16 `json:"id"` Name string `json:"name"` Value uint16 `json:"value"` Worst uint16 `json:"worst"` Thresh uint16 `json:"thresh"` WhenFailed string `json:"when_failed"` // Flags AttributeFlags `json:"flags"` Raw RawValue `json:"raw"` } // type AttributeFlags struct { // Value int `json:"value"` // String string `json:"string"` // Prefailure bool `json:"prefailure"` // UpdatedOnline bool `json:"updated_online"` // Performance bool `json:"performance"` // ErrorRate bool `json:"error_rate"` // EventCount bool `json:"event_count"` // AutoKeep bool `json:"auto_keep"` // } type RawValue struct { Value SmartRawValue `json:"value"` String string `json:"string"` } func (r *RawValue) UnmarshalJSON(data []byte) error { var tmp struct { Value json.RawMessage `json:"value"` String string `json:"string"` } if err := json.Unmarshal(data, &tmp); err != nil { return err } if len(tmp.Value) > 0 { if err := r.Value.UnmarshalJSON(tmp.Value); err != nil { return err } } else { r.Value = 0 } r.String = tmp.String if parsed, ok := ParseSmartRawValueString(tmp.String); ok { r.Value = SmartRawValue(parsed) } return nil } type SmartRawValue uint64 // handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours func (v *SmartRawValue) UnmarshalJSON(data []byte) error { trimmed := strings.TrimSpace(string(data)) if len(trimmed) == 0 || trimmed == "null" { *v = 0 return nil } if trimmed[0] == '"' { valueStr, err := strconv.Unquote(trimmed) if err != nil { return err } parsed, ok := ParseSmartRawValueString(valueStr) if ok { *v = SmartRawValue(parsed) return nil } *v = 0 return nil } if parsed, err := strconv.ParseUint(trimmed, 0, 64); err == nil { *v = SmartRawValue(parsed) return nil } if parsed, ok := ParseSmartRawValueString(trimmed); ok { *v = SmartRawValue(parsed) return nil } *v = 0 return nil } // ParseSmartRawValueString attempts to extract a numeric value from the raw value // strings emitted by smartctl, which sometimes include human-friendly annotations // like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a // boolean indicating success. func ParseSmartRawValueString(value string) (uint64, bool) { value = strings.TrimSpace(value) if value == "" { return 0, false } if parsed, err := strconv.ParseUint(value, 0, 64); err == nil { return parsed, true } if idx := strings.IndexRune(value, 'h'); idx > 0 { hoursPart := strings.TrimSpace(value[:idx]) if hoursPart != "" { if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil { return uint64(parsed), true } } } for i := 0; i < len(value); i++ { if value[i] < '0' || value[i] > '9' { continue } end := i + 1 for end < len(value) && value[end] >= '0' && value[end] <= '9' { end++ } digits := value[i:end] if parsed, err := strconv.ParseUint(digits, 10, 64); err == nil { return parsed, true } i = end } return 0, false } // type PowerOnTimeInfo struct { // Hours uint32 `json:"hours"` // } type TemperatureInfo struct { Current uint8 `json:"current"` } type TemperatureInfoScsi struct { Current uint8 `json:"current"` DriveTrip uint8 `json:"drive_trip"` } // type SelectiveSelfTestTable struct { // LbaMin int `json:"lba_min"` // LbaMax int `json:"lba_max"` // Status StatusInfo `json:"status"` // } // type SelectiveSelfTestFlags struct { // Value int `json:"value"` // RemainderScanEnabled bool `json:"remainder_scan_enabled"` // } // type AtaSmartSelectiveSelfTestLog struct { // Revision int `json:"revision"` // Table []SelectiveSelfTestTable `json:"table"` // Flags SelectiveSelfTestFlags `json:"flags"` // PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"` // } // BaseSmartInfo contains common fields shared between SATA and NVMe drives // type BaseSmartInfo struct { // Device DeviceInfo `json:"device"` // ModelName string `json:"model_name"` // SerialNumber string `json:"serial_number"` // FirmwareVersion string `json:"firmware_version"` // UserCapacity UserCapacity `json:"user_capacity"` // LogicalBlockSize int `json:"logical_block_size"` // LocalTime LocalTime `json:"local_time"` // } type SmartctlInfoLegacy struct { Version VersionInfo `json:"version"` SvnRevision string `json:"svn_revision"` PlatformInfo string `json:"platform_info"` BuildInfo string `json:"build_info"` Argv []string `json:"argv"` ExitStatus int `json:"exit_status"` } type SmartInfoForSata struct { // JSONFormatVersion VersionInfo `json:"json_format_version"` Smartctl SmartctlInfoLegacy `json:"smartctl"` Device DeviceInfo `json:"device"` // ModelFamily string `json:"model_family"` ModelName string `json:"model_name"` SerialNumber string `json:"serial_number"` // Wwn WwnInfo `json:"wwn"` FirmwareVersion string `json:"firmware_version"` UserCapacity UserCapacity `json:"user_capacity"` ScsiVendor string `json:"scsi_vendor"` ScsiProduct string `json:"scsi_product"` // LogicalBlockSize int `json:"logical_block_size"` // PhysicalBlockSize int `json:"physical_block_size"` // RotationRate int `json:"rotation_rate"` // FormFactor FormFactorInfo `json:"form_factor"` // Trim TrimInfo `json:"trim"` // InSmartctlDatabase bool `json:"in_smartctl_database"` // AtaVersion AtaVersionInfo `json:"ata_version"` // SataVersion VersionStringInfo `json:"sata_version"` // InterfaceSpeed InterfaceSpeedInfo `json:"interface_speed"` // LocalTime LocalTime `json:"local_time"` SmartStatus SmartStatusInfo `json:"smart_status"` // AtaSmartData AtaSmartData `json:"ata_smart_data"` // AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"` AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"` AtaDeviceStatistics json.RawMessage `json:"ata_device_statistics"` // PowerOnTime PowerOnTimeInfo `json:"power_on_time"` // PowerCycleCount uint16 `json:"power_cycle_count"` Temperature TemperatureInfo `json:"temperature"` // AtaSmartErrorLog AtaSmartErrorLog `json:"ata_smart_error_log"` // AtaSmartSelfTestLog AtaSmartSelfTestLog `json:"ata_smart_self_test_log"` // AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"` } type ScsiErrorCounter struct { ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"` ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"` ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"` TotalErrorsCorrected uint64 `json:"total_errors_corrected"` CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"` GigabytesProcessed string `json:"gigabytes_processed"` TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"` } type ScsiErrorCounterLog struct { Read ScsiErrorCounter `json:"read"` Write ScsiErrorCounter `json:"write"` Verify ScsiErrorCounter `json:"verify"` } type ScsiStartStopCycleCounter struct { YearOfManufacture string `json:"year_of_manufacture"` WeekOfManufacture string `json:"week_of_manufacture"` SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"` AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"` SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"` AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"` } type PowerOnTimeScsi struct { Hours uint64 `json:"hours"` Minutes uint64 `json:"minutes"` } type SmartInfoForScsi struct { Smartctl SmartctlInfoLegacy `json:"smartctl"` Device DeviceInfo `json:"device"` ScsiVendor string `json:"scsi_vendor"` ScsiProduct string `json:"scsi_product"` ScsiModelName string `json:"scsi_model_name"` ScsiRevision string `json:"scsi_revision"` ScsiVersion string `json:"scsi_version"` SerialNumber string `json:"serial_number"` UserCapacity UserCapacity `json:"user_capacity"` Temperature TemperatureInfoScsi `json:"temperature"` SmartStatus SmartStatusInfo `json:"smart_status"` PowerOnTime PowerOnTimeScsi `json:"power_on_time"` ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"` ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"` ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_log"` } // type AtaSmartErrorLog struct { // Summary SummaryInfo `json:"summary"` // } // type AtaSmartSelfTestLog struct { // Standard SummaryInfo `json:"standard"` // } type SmartctlInfoNvme struct { Version VersionInfo `json:"version"` SVNRevision string `json:"svn_revision"` PlatformInfo string `json:"platform_info"` BuildInfo string `json:"build_info"` Argv []string `json:"argv"` ExitStatus int `json:"exit_status"` } // type NVMePCIVendor struct { // ID int `json:"id"` // SubsystemID int `json:"subsystem_id"` // } // type SizeCapacityInfo struct { // Blocks uint64 `json:"blocks"` // Bytes uint64 `json:"bytes"` // } // type EUI64Info struct { // OUI int `json:"oui"` // ExtID int `json:"ext_id"` // } // type NVMeNamespace struct { // ID uint32 `json:"id"` // Size SizeCapacityInfo `json:"size"` // Capacity SizeCapacityInfo `json:"capacity"` // Utilization SizeCapacityInfo `json:"utilization"` // FormattedLBASize uint32 `json:"formatted_lba_size"` // EUI64 EUI64Info `json:"eui64"` // } type SmartStatusInfoNvme struct { Passed bool `json:"passed"` NVMe SmartStatusNVMe `json:"nvme"` } type SmartStatusNVMe struct { Value int `json:"value"` } type NVMeSmartHealthInformationLog struct { CriticalWarning uint `json:"critical_warning"` Temperature uint8 `json:"temperature"` AvailableSpare uint `json:"available_spare"` AvailableSpareThreshold uint `json:"available_spare_threshold"` PercentageUsed uint8 `json:"percentage_used"` DataUnitsRead uint64 `json:"data_units_read"` DataUnitsWritten uint64 `json:"data_units_written"` HostReads uint `json:"host_reads"` HostWrites uint `json:"host_writes"` ControllerBusyTime uint `json:"controller_busy_time"` PowerCycles uint16 `json:"power_cycles"` PowerOnHours uint32 `json:"power_on_hours"` UnsafeShutdowns uint16 `json:"unsafe_shutdowns"` MediaErrors uint `json:"media_errors"` NumErrLogEntries uint `json:"num_err_log_entries"` WarningTempTime uint `json:"warning_temp_time"` CriticalCompTime uint `json:"critical_comp_time"` TemperatureSensors []uint8 `json:"temperature_sensors"` } type SmartInfoForNvme struct { // JSONFormatVersion VersionInfo `json:"json_format_version"` Smartctl SmartctlInfoNvme `json:"smartctl"` Device DeviceInfo `json:"device"` ModelName string `json:"model_name"` SerialNumber string `json:"serial_number"` FirmwareVersion string `json:"firmware_version"` // NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"` // NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"` // NVMeTotalCapacity uint64 `json:"nvme_total_capacity"` // NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"` // NVMeControllerID uint16 `json:"nvme_controller_id"` // NVMeVersion VersionStringInfo `json:"nvme_version"` // NVMeNumberOfNamespaces uint8 `json:"nvme_number_of_namespaces"` // NVMeNamespaces []NVMeNamespace `json:"nvme_namespaces"` UserCapacity UserCapacity `json:"user_capacity"` // LogicalBlockSize int `json:"logical_block_size"` // LocalTime LocalTime `json:"local_time"` SmartStatus SmartStatusInfoNvme `json:"smart_status"` NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"` Temperature TemperatureInfoNvme `json:"temperature"` PowerCycleCount uint16 `json:"power_cycle_count"` PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"` } type TemperatureInfoNvme struct { Current int `json:"current"` } type PowerOnTimeInfoNvme struct { Hours int `json:"hours"` } type SmartData struct { // ModelFamily string `json:"mf,omitempty" cbor:"0,keyasint,omitempty"` ModelName string `json:"mn,omitempty" cbor:"1,keyasint,omitempty"` SerialNumber string `json:"sn,omitempty" cbor:"2,keyasint,omitempty"` FirmwareVersion string `json:"fv,omitempty" cbor:"3,keyasint,omitempty"` Capacity uint64 `json:"c,omitempty" cbor:"4,keyasint,omitempty"` SmartStatus string `json:"s,omitempty" cbor:"5,keyasint,omitempty"` DiskName string `json:"dn,omitempty" cbor:"6,keyasint,omitempty"` DiskType string `json:"dt,omitempty" cbor:"7,keyasint,omitempty"` Temperature uint8 `json:"t,omitempty" cbor:"8,keyasint,omitempty"` Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"` } type SmartAttribute struct { ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"` Name string `json:"n" cbor:"1,keyasint"` Value uint16 `json:"v,omitempty" cbor:"2,keyasint,omitempty"` Worst uint16 `json:"w,omitempty" cbor:"3,keyasint,omitempty"` Threshold uint16 `json:"t,omitempty" cbor:"4,keyasint,omitempty"` RawValue uint64 `json:"rv" cbor:"5,keyasint"` RawString string `json:"rs,omitempty" cbor:"6,keyasint,omitempty"` WhenFailed string `json:"wf,omitempty" cbor:"7,keyasint,omitempty"` } ================================================ FILE: internal/entities/smart/smart_test.go ================================================ package smart import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" ) func TestSmartRawValueUnmarshalDuration(t *testing.T) { input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 62312, raw.Value) } func TestSmartRawValueUnmarshalNumericString(t *testing.T) { input := []byte(`{"value":"7344","string":"7344"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 7344, raw.Value) } func TestSmartRawValueUnmarshalParenthetical(t *testing.T) { input := []byte(`{"value":"39925 (212 206 0)","string":"39925 (212 206 0)"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 39925, raw.Value) } func TestSmartRawValueUnmarshalDurationWithFractions(t *testing.T) { input := []byte(`{"value":"2748h+31m+49.560s","string":"2748h+31m+49.560s"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 2748, raw.Value) } func TestSmartRawValueUnmarshalParentheticalRawValue(t *testing.T) { input := []byte(`{"value":57891864217128,"string":"39925 (212 206 0)"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 39925, raw.Value) } func TestSmartRawValueUnmarshalDurationRawValue(t *testing.T) { input := []byte(`{"value":57891864217128,"string":"2748h+31m+49.560s"}`) var raw RawValue err := json.Unmarshal(input, &raw) assert.NoError(t, err) assert.EqualValues(t, 2748, raw.Value) } ================================================ FILE: internal/entities/system/system.go ================================================ package system // TODO: this is confusing, make common package with common/types common/helpers etc import ( "encoding/json" "time" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/systemd" ) type Stats struct { Cpu float64 `json:"cpu" cbor:"0,keyasint"` MaxCpu float64 `json:"cpum,omitempty" cbor:"-"` Mem float64 `json:"m" cbor:"2,keyasint"` MaxMem float64 `json:"mm,omitempty" cbor:"-"` MemUsed float64 `json:"mu" cbor:"3,keyasint"` MemPct float64 `json:"mp" cbor:"4,keyasint"` MemBuffCache float64 `json:"mb" cbor:"5,keyasint"` MemZfsArc float64 `json:"mz,omitempty" cbor:"6,keyasint,omitempty"` // ZFS ARC memory Swap float64 `json:"s,omitempty" cbor:"7,keyasint,omitempty"` SwapUsed float64 `json:"su,omitempty" cbor:"8,keyasint,omitempty"` DiskTotal float64 `json:"d" cbor:"9,keyasint"` DiskUsed float64 `json:"du" cbor:"10,keyasint"` DiskPct float64 `json:"dp" cbor:"11,keyasint"` DiskReadPs float64 `json:"dr,omitzero" cbor:"12,keyasint,omitzero"` DiskWritePs float64 `json:"dw,omitzero" cbor:"13,keyasint,omitzero"` MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"-"` MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"-"` NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"` NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"` MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"-"` MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"-"` Temperatures map[string]float64 `json:"t,omitempty" cbor:"20,keyasint,omitempty"` ExtraFs map[string]*FsStats `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` GPUData map[string]GPUData `json:"g,omitempty" cbor:"22,keyasint,omitempty"` // LoadAvg1 float64 `json:"l1,omitempty" cbor:"23,keyasint,omitempty"` // LoadAvg5 float64 `json:"l5,omitempty" cbor:"24,keyasint,omitempty"` // LoadAvg15 float64 `json:"l15,omitempty" cbor:"25,keyasint,omitempty"` Bandwidth [2]uint64 `json:"b,omitzero" cbor:"26,keyasint,omitzero"` // [sent bytes, recv bytes] MaxBandwidth [2]uint64 `json:"bm,omitzero" cbor:"-"` // [sent bytes, recv bytes] // TODO: remove other load fields in future release in favor of load avg array LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"` Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current] NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download] DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes] MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes] CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle] CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..] } // Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient. // JSON: encodes as array of numbers (avoids base64 string). // CBOR: falls back to default handling for []uint8 (byte string), keeping payload small. type Uint8Slice []uint8 func (s Uint8Slice) MarshalJSON() ([]byte, error) { if s == nil { return []byte("null"), nil } // Convert to wider ints to force array-of-numbers encoding. arr := make([]uint16, len(s)) for i, v := range s { arr[i] = uint16(v) } return json.Marshal(arr) } type GPUData struct { Name string `json:"n" cbor:"0,keyasint"` Temperature float64 `json:"-"` MemoryUsed float64 `json:"mu,omitempty,omitzero" cbor:"1,keyasint,omitempty,omitzero"` MemoryTotal float64 `json:"mt,omitempty,omitzero" cbor:"2,keyasint,omitempty,omitzero"` Usage float64 `json:"u" cbor:"3,keyasint,omitempty"` Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"` Count float64 `json:"-"` Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"` PowerPkg float64 `json:"pp,omitempty" cbor:"6,keyasint,omitempty"` } type FsStats struct { Time time.Time `json:"-"` Root bool `json:"-"` Mountpoint string `json:"-"` Name string `json:"-"` DiskTotal float64 `json:"d" cbor:"0,keyasint"` DiskUsed float64 `json:"du" cbor:"1,keyasint"` TotalRead uint64 `json:"-"` TotalWrite uint64 `json:"-"` DiskReadPs float64 `json:"r" cbor:"2,keyasint"` DiskWritePs float64 `json:"w" cbor:"3,keyasint"` MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"` MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"` // TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"` DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"` MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"` MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"` } type NetIoStats struct { BytesRecv uint64 BytesSent uint64 Time time.Time Name string } type Os = uint8 const ( Linux Os = iota Darwin Windows Freebsd ) type ConnectionType = uint8 const ( ConnectionTypeNone ConnectionType = iota ConnectionTypeSSH ConnectionTypeWebSocket ) // Core system data that is needed in All Systems table type Info struct { Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct // Threads is needed in Info struct to calculate load average thresholds Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"` CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct Uptime uint64 `json:"u" cbor:"5,keyasint"` Cpu float64 `json:"cpu" cbor:"6,keyasint"` MemPct float64 `json:"mp" cbor:"7,keyasint"` DiskPct float64 `json:"dp" cbor:"8,keyasint"` Bandwidth float64 `json:"b,omitzero" cbor:"9,keyasint"` // deprecated in favor of BandwidthBytes AgentVersion string `json:"v" cbor:"10,keyasint"` Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct // LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead // LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead // LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"` LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"` ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"` ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"` Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices] Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state] } // Data that does not change during process lifetime and is not needed in All Systems table type Details struct { Hostname string `cbor:"0,keyasint"` Kernel string `cbor:"1,keyasint,omitempty"` Cores int `cbor:"2,keyasint"` Threads int `cbor:"3,keyasint"` CpuModel string `cbor:"4,keyasint"` Os Os `cbor:"5,keyasint"` OsName string `cbor:"6,keyasint"` Arch string `cbor:"7,keyasint"` Podman bool `cbor:"8,keyasint,omitempty"` MemoryTotal uint64 `cbor:"9,keyasint"` SmartInterval time.Duration `cbor:"10,keyasint,omitempty"` } // Final data structure to return to the hub type CombinedData struct { Stats Stats `json:"stats" cbor:"0,keyasint"` Info Info `json:"info" cbor:"1,keyasint"` Containers []*container.Stats `json:"container" cbor:"2,keyasint"` SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` Details *Details `cbor:"4,keyasint,omitempty"` } ================================================ FILE: internal/entities/systemd/systemd.go ================================================ package systemd import ( "math" "runtime" "time" ) // ServiceState represents the status of a systemd service type ServiceState uint8 const ( StatusActive ServiceState = iota StatusInactive StatusFailed StatusActivating StatusDeactivating StatusReloading ) // ServiceSubState represents the sub status of a systemd service type ServiceSubState uint8 const ( SubStateDead ServiceSubState = iota SubStateRunning SubStateExited SubStateFailed SubStateUnknown ) // ParseServiceStatus converts a string status to a ServiceStatus enum value func ParseServiceStatus(status string) ServiceState { switch status { case "active": return StatusActive case "inactive": return StatusInactive case "failed": return StatusFailed case "activating": return StatusActivating case "deactivating": return StatusDeactivating case "reloading": return StatusReloading default: return StatusInactive } } // ParseServiceSubState converts a string sub status to a ServiceSubState enum value func ParseServiceSubState(subState string) ServiceSubState { switch subState { case "dead": return SubStateDead case "running": return SubStateRunning case "exited": return SubStateExited case "failed": return SubStateFailed default: return SubStateUnknown } } // Service represents a single systemd service with its stats. type Service struct { Name string `json:"n" cbor:"0,keyasint"` State ServiceState `json:"s" cbor:"1,keyasint"` Cpu float64 `json:"c" cbor:"2,keyasint"` Mem uint64 `json:"m" cbor:"3,keyasint"` MemPeak uint64 `json:"mp" cbor:"4,keyasint"` Sub ServiceSubState `json:"ss" cbor:"5,keyasint"` CpuPeak float64 `json:"cp" cbor:"6,keyasint"` PrevCpuUsage uint64 `json:"-"` PrevReadTime time.Time `json:"-"` } // UpdateCPUPercent calculates the CPU usage percentage for the service. func (s *Service) UpdateCPUPercent(cpuUsage uint64) { now := time.Now() if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage { s.Cpu = 0 s.PrevCpuUsage = cpuUsage s.PrevReadTime = now return } duration := now.Sub(s.PrevReadTime).Nanoseconds() if duration <= 0 { s.PrevCpuUsage = cpuUsage s.PrevReadTime = now return } coreCount := int64(runtime.NumCPU()) duration *= coreCount usageDelta := cpuUsage - s.PrevCpuUsage cpuPercent := float64(usageDelta) / float64(duration) s.Cpu = twoDecimals(cpuPercent * 100) if s.Cpu > s.CpuPeak { s.CpuPeak = s.Cpu } s.PrevCpuUsage = cpuUsage s.PrevReadTime = now } func twoDecimals(value float64) float64 { return math.Round(value*100) / 100 } // ServiceDependency represents a unit that the service depends on. type ServiceDependency struct { Name string `json:"name"` Description string `json:"description,omitempty"` ActiveState string `json:"activeState,omitempty"` SubState string `json:"subState,omitempty"` } // ServiceDetails contains extended information about a systemd service. type ServiceDetails map[string]any ================================================ FILE: internal/entities/systemd/systemd_test.go ================================================ //go:build testing package systemd_test import ( "testing" "time" "github.com/henrygd/beszel/internal/entities/systemd" "github.com/stretchr/testify/assert" ) func TestParseServiceStatus(t *testing.T) { tests := []struct { input string expected systemd.ServiceState }{ {"active", systemd.StatusActive}, {"inactive", systemd.StatusInactive}, {"failed", systemd.StatusFailed}, {"activating", systemd.StatusActivating}, {"deactivating", systemd.StatusDeactivating}, {"reloading", systemd.StatusReloading}, {"unknown", systemd.StatusInactive}, // default case {"", systemd.StatusInactive}, // default case } for _, test := range tests { t.Run(test.input, func(t *testing.T) { result := systemd.ParseServiceStatus(test.input) assert.Equal(t, test.expected, result) }) } } func TestParseServiceSubState(t *testing.T) { tests := []struct { input string expected systemd.ServiceSubState }{ {"dead", systemd.SubStateDead}, {"running", systemd.SubStateRunning}, {"exited", systemd.SubStateExited}, {"failed", systemd.SubStateFailed}, {"unknown", systemd.SubStateUnknown}, {"other", systemd.SubStateUnknown}, // default case {"", systemd.SubStateUnknown}, // default case } for _, test := range tests { t.Run(test.input, func(t *testing.T) { result := systemd.ParseServiceSubState(test.input) assert.Equal(t, test.expected, result) }) } } func TestServiceUpdateCPUPercent(t *testing.T) { t.Run("initial call sets CPU to 0", func(t *testing.T) { service := &systemd.Service{} service.UpdateCPUPercent(1000) assert.Equal(t, 0.0, service.Cpu) assert.Equal(t, uint64(1000), service.PrevCpuUsage) assert.False(t, service.PrevReadTime.IsZero()) }) t.Run("subsequent call calculates CPU percentage", func(t *testing.T) { service := &systemd.Service{} service.PrevCpuUsage = 1000 service.PrevReadTime = time.Now().Add(-time.Second) service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time // CPU usage should be positive and reasonable assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive") assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%") assert.Equal(t, uint64(8000000000), service.PrevCpuUsage) assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set") }) t.Run("CPU peak updates only when higher", func(t *testing.T) { service := &systemd.Service{} service.PrevCpuUsage = 1000 service.PrevReadTime = time.Now().Add(-time.Second) service.UpdateCPUPercent(8000000000) // Set initial peak to ~50% initialPeak := service.CpuPeak // Now try with much lower CPU usage - should not update peak service.PrevReadTime = time.Now().Add(-time.Second) service.UpdateCPUPercent(1000000) // Much lower usage assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage") }) t.Run("handles zero duration", func(t *testing.T) { service := &systemd.Service{} service.PrevCpuUsage = 1000 now := time.Now() service.PrevReadTime = now // Mock time.Now() to return the same time to ensure zero duration // Since we can't mock time in Go easily, we'll check the logic manually // The zero duration case happens when duration <= 0 assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0") }) t.Run("handles CPU usage wraparound", func(t *testing.T) { service := &systemd.Service{} // Simulate wraparound where new usage is less than previous service.PrevCpuUsage = 1000 service.PrevReadTime = time.Now().Add(-time.Second) service.UpdateCPUPercent(500) // Less than previous, should reset assert.Equal(t, 0.0, service.Cpu) }) } ================================================ FILE: internal/ghupdate/extract.go ================================================ package ghupdate import ( "archive/tar" "archive/zip" "compress/gzip" "fmt" "io" "os" "path/filepath" "strings" ) // extract extracts an archive file to the destination directory. // Supports .zip and .tar.gz files based on the file extension. func extract(srcPath, destDir string) error { if strings.HasSuffix(srcPath, ".tar.gz") { return extractTarGz(srcPath, destDir) } // Default to zip extraction return extractZip(srcPath, destDir) } // extractTarGz extracts a tar.gz archive to the destination directory. func extractTarGz(srcPath, destDir string) error { src, err := os.Open(srcPath) if err != nil { return err } defer src.Close() gz, err := gzip.NewReader(src) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return err } if header.Typeflag == tar.TypeDir { if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil { return err } outFile, err := os.Create(filepath.Join(destDir, header.Name)) if err != nil { return err } if _, err := io.Copy(outFile, tr); err != nil { outFile.Close() return err } outFile.Close() } return nil } // extractZip extracts the zip archive at "src" to "dest". // // Note that only dirs and regular files will be extracted. // Symbolic links, named pipes, sockets, or any other irregular files // are skipped because they come with too many edge cases and ambiguities. func extractZip(src, dest string) error { zr, err := zip.OpenReader(src) if err != nil { return err } defer zr.Close() // normalize dest path to check later for Zip Slip dest = filepath.Clean(dest) + string(os.PathSeparator) for _, f := range zr.File { err := extractFile(f, dest) if err != nil { return err } } return nil } // extractFile extracts the provided zipFile into "basePath/zipFileName" path, // creating all the necessary path directories. func extractFile(zipFile *zip.File, basePath string) error { path := filepath.Join(basePath, zipFile.Name) // check for Zip Slip if !strings.HasPrefix(path, basePath) { return fmt.Errorf("invalid file path: %s", path) } r, err := zipFile.Open() if err != nil { return err } defer r.Close() // allow only dirs or regular files if zipFile.FileInfo().IsDir() { if err := os.MkdirAll(path, os.ModePerm); err != nil { return err } } else if zipFile.FileInfo().Mode().IsRegular() { // ensure that the file path directories are created if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) if err != nil { return err } defer f.Close() _, err = io.Copy(f, r) if err != nil { return err } } return nil } ================================================ FILE: internal/ghupdate/ghupdate.go ================================================ // Package ghupdate implements a new command to self update the current // executable with the latest GitHub release. This is based on PocketBase's // ghupdate package with modifications. package ghupdate import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "github.com/henrygd/beszel" "github.com/blang/semver" ) // Minimal color functions using ANSI escape codes const ( colorReset = "\033[0m" ColorYellow = "\033[33m" ColorGreen = "\033[32m" colorCyan = "\033[36m" colorGray = "\033[90m" ) func ColorPrint(color, text string) { fmt.Println(color + text + colorReset) } func ColorPrintf(color, format string, args ...any) { fmt.Printf(color+format+colorReset+"\n", args...) } // HttpClient is a base HTTP client interface (usually used for test purposes). type HttpClient interface { Do(req *http.Request) (*http.Response, error) } // Config defines the config options of the ghupdate plugin. // // NB! This plugin is considered experimental and its config options may change in the future. type Config struct { // Owner specifies the account owner of the repository (default to "pocketbase"). Owner string // Repo specifies the name of the repository (default to "pocketbase"). Repo string // ArchiveExecutable specifies the name of the executable file in the release archive // (default to "pocketbase"; an additional ".exe" check is also performed as a fallback). ArchiveExecutable string // Optional context to use when fetching and downloading the latest release. Context context.Context // The HTTP client to use when fetching and downloading the latest release. // Defaults to `http.DefaultClient`. HttpClient HttpClient // The data directory to use when fetching and downloading the latest release. DataDir string // UseMirror specifies whether to use the beszel.dev mirror instead of GitHub API. // When false (default), always uses api.github.com. When true, uses gh.beszel.dev. UseMirror bool } type updater struct { config Config currentVersion string } func Update(config Config) (updated bool, err error) { p := &updater{ currentVersion: beszel.Version, config: config, } return p.update() } func (p *updater) update() (updated bool, err error) { ColorPrint(ColorYellow, "Fetching release information...") if p.config.DataDir == "" { p.config.DataDir = os.TempDir() } if p.config.Owner == "" { p.config.Owner = "henrygd" } if p.config.Repo == "" { p.config.Repo = "beszel" } if p.config.Context == nil { p.config.Context = context.Background() } if p.config.HttpClient == nil { p.config.HttpClient = http.DefaultClient } var latest *release var useMirror bool // Determine the API endpoint based on UseMirror flag apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo) if p.config.UseMirror { useMirror = true apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo) ColorPrint(ColorYellow, "Using mirror for update.") } latest, err = fetchLatestRelease( p.config.Context, p.config.HttpClient, apiURL, ) if err != nil { return false, err } currentVersion := semver.MustParse(strings.TrimPrefix(p.currentVersion, "v")) newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v")) if newVersion.LTE(currentVersion) { ColorPrintf(ColorGreen, "You already have the latest version %s.", p.currentVersion) return false, nil } suffix := archiveSuffix(p.config.ArchiveExecutable, runtime.GOOS, runtime.GOARCH) asset, err := latest.findAssetBySuffix(suffix) if err != nil { return false, err } releaseDir := filepath.Join(p.config.DataDir, ".beszel_update") defer os.RemoveAll(releaseDir) ColorPrintf(ColorYellow, "Downloading %s...", asset.Name) // download the release asset assetPath := filepath.Join(releaseDir, asset.Name) if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil { return false, err } ColorPrintf(ColorYellow, "Extracting %s...", asset.Name) extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name) defer os.RemoveAll(extractDir) // Extract the archive (automatically detects format) if err := extract(assetPath, extractDir); err != nil { return false, err } ColorPrint(ColorYellow, "Replacing the executable...") oldExec, err := os.Executable() if err != nil { return false, err } renamedOldExec := oldExec + ".old" defer os.Remove(renamedOldExec) newExec := filepath.Join(extractDir, p.config.ArchiveExecutable) if _, err := os.Stat(newExec); err != nil { // try again with an .exe extension newExec = newExec + ".exe" if _, fallbackErr := os.Stat(newExec); fallbackErr != nil { return false, fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr) } } // rename the current executable if err := os.Rename(oldExec, renamedOldExec); err != nil { return false, fmt.Errorf("failed to rename the current executable: %w", err) } tryToRevertExecChanges := func() { if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil { slog.Debug( "Failed to revert executable", slog.String("old", renamedOldExec), slog.String("new", oldExec), slog.String("error", revertErr.Error()), ) } } // replace with the extracted binary if err := os.Rename(newExec, oldExec); err != nil { // If rename fails due to cross-device link, try copying instead if isCrossDeviceError(err) { if err := copyFile(newExec, oldExec); err != nil { tryToRevertExecChanges() return false, fmt.Errorf("failed replacing the executable: %w", err) } } else { tryToRevertExecChanges() return false, fmt.Errorf("failed replacing the executable: %w", err) } } ColorPrint(colorGray, "---") ColorPrint(ColorGreen, "Update completed successfully!") // print the release notes if latest.Body != "" { fmt.Print("\n") releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1)) ColorPrint(colorCyan, releaseNotes) fmt.Print("\n") } return true, nil } func fetchLatestRelease( ctx context.Context, client HttpClient, url string, ) (*release, error) { req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } res, err := client.Do(req) if err != nil { return nil, err } defer res.Body.Close() rawBody, err := io.ReadAll(res.Body) if err != nil { return nil, err } // http.Client doesn't treat non 2xx responses as error if res.StatusCode >= 400 { return nil, fmt.Errorf( "(%d) failed to fetch latest releases:\n%s", res.StatusCode, string(rawBody), ) } result := &release{} if err := json.Unmarshal(rawBody, result); err != nil { return nil, err } return result, nil } func downloadFile( ctx context.Context, client HttpClient, url string, destPath string, useMirror bool, ) error { if useMirror { url = strings.Replace(url, "github.com", "gh.beszel.dev", 1) } req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return err } res, err := client.Do(req) if err != nil { return err } defer res.Body.Close() // http.Client doesn't treat non 2xx responses as error if res.StatusCode >= 400 { return fmt.Errorf("(%d) failed to send download file request", res.StatusCode) } // ensure that the dest parent dir(s) exist if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { return err } dest, err := os.Create(destPath) if err != nil { return err } defer dest.Close() if _, err := io.Copy(dest, res.Body); err != nil { return err } return nil } // isCrossDeviceError checks if the error is due to a cross-device link func isCrossDeviceError(err error) bool { return err != nil && (strings.Contains(err.Error(), "cross-device") || strings.Contains(err.Error(), "EXDEV")) } // copyFile copies a file from src to dst, preserving permissions func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() // Copy the file contents if _, err := io.Copy(destFile, sourceFile); err != nil { return err } // Preserve the original file permissions sourceInfo, err := sourceFile.Stat() if err != nil { return err } return destFile.Chmod(sourceInfo.Mode()) } func archiveSuffix(binaryName, goos, goarch string) string { if goos == "windows" { return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch) } // Use glibc build for agent on glibc systems (includes NVML support via purego) if binaryName == "beszel-agent" && goos == "linux" && goarch == "amd64" && isGlibc() { return fmt.Sprintf("%s_%s_%s_glibc.tar.gz", binaryName, goos, goarch) } return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch) } func isGlibc() bool { for _, path := range []string{ "/lib64/ld-linux-x86-64.so.2", // common on many distros "/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", // Debian/Ubuntu "/lib/ld-linux-x86-64.so.2", // alternate } { if _, err := os.Stat(path); err == nil { return true } } // Fallback to ldd output when present (musl ldd reports musl, glibc reports GNU libc/glibc). if lddPath, err := exec.LookPath("ldd"); err == nil { out, err := exec.Command(lddPath, "--version").CombinedOutput() if err == nil { s := strings.ToLower(string(out)) if strings.Contains(s, "gnu libc") || strings.Contains(s, "glibc") { return true } } } return false } ================================================ FILE: internal/ghupdate/ghupdate_test.go ================================================ package ghupdate import ( "path/filepath" "testing" ) func TestReleaseFindAssetBySuffix(t *testing.T) { r := release{ Assets: []*releaseAsset{ {Name: "test1.zip", Id: 1}, {Name: "test2.zip", Id: 2}, {Name: "test22.zip", Id: 22}, {Name: "test3.zip", Id: 3}, }, } asset, err := r.findAssetBySuffix("2.zip") if err != nil { t.Fatalf("Expected nil, got err: %v", err) } if asset.Id != 2 { t.Fatalf("Expected asset with id %d, got %v", 2, asset) } } func TestExtractFailure(t *testing.T) { testDir := t.TempDir() // Test with missing zip file missingZipPath := filepath.Join(testDir, "missing_test.zip") extractedPath := filepath.Join(testDir, "zip_extract") if err := extract(missingZipPath, extractedPath); err == nil { t.Fatal("Expected Extract to fail due to missing zip file") } // Test with missing tar.gz file missingTarPath := filepath.Join(testDir, "missing_test.tar.gz") if err := extract(missingTarPath, extractedPath); err == nil { t.Fatal("Expected Extract to fail due to missing tar.gz file") } } ================================================ FILE: internal/ghupdate/release.go ================================================ package ghupdate import ( "errors" "strings" ) type releaseAsset struct { Name string `json:"name"` DownloadUrl string `json:"browser_download_url"` Id int `json:"id"` Size int `json:"size"` } type release struct { Name string `json:"name"` Tag string `json:"tag_name"` Published string `json:"published_at"` Url string `json:"html_url"` Body string `json:"body"` Assets []*releaseAsset `json:"assets"` Id int `json:"id"` } // findAssetBySuffix returns the first available asset containing the specified suffix. func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) { if suffix != "" { for _, asset := range r.Assets { if strings.HasSuffix(asset.Name, suffix) { return asset, nil } } } return nil, errors.New("missing asset containing " + suffix) } ================================================ FILE: internal/ghupdate/selinux.go ================================================ package ghupdate import ( "fmt" "os/exec" "strings" ) // HandleSELinuxContext restores or applies the correct SELinux label to the binary. func HandleSELinuxContext(path string) error { out, err := exec.Command("getenforce").Output() if err != nil { // SELinux not enabled or getenforce not available return nil } state := strings.TrimSpace(string(out)) if state == "Disabled" { return nil } ColorPrint(ColorYellow, "SELinux is enabled; applying context…") // Try persistent context via semanage+restorecon if success := trySemanageRestorecon(path); success { return nil } // Fallback to temporary context via chcon if chconPath, err := exec.LookPath("chcon"); err == nil { if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil { return fmt.Errorf("chcon failed: %w", err) } return nil } return fmt.Errorf("no SELinux tools available (semanage/restorecon or chcon)") } // trySemanageRestorecon attempts to set persistent SELinux context using semanage and restorecon. // Returns true if successful, false otherwise. func trySemanageRestorecon(path string) bool { semanagePath, err := exec.LookPath("semanage") if err != nil { return false } restoreconPath, err := exec.LookPath("restorecon") if err != nil { return false } // Try to add the fcontext rule; if it already exists, try to modify it if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil { // Rule may already exist, try modify instead if err := exec.Command(semanagePath, "fcontext", "-m", "-t", "bin_t", path).Run(); err != nil { return false } } // Apply the context with restorecon if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil { return false } return true } ================================================ FILE: internal/ghupdate/selinux_test.go ================================================ package ghupdate import ( "os" "os/exec" "path/filepath" "testing" ) func TestHandleSELinuxContext_NoSELinux(t *testing.T) { // Skip on SELinux systems - this test is for non-SELinux behavior if _, err := exec.LookPath("getenforce"); err == nil { t.Skip("skipping on SELinux-enabled system") } // On systems without SELinux, getenforce will fail and the function // should return nil without error tempFile := filepath.Join(t.TempDir(), "test-binary") if err := os.WriteFile(tempFile, []byte("test"), 0755); err != nil { t.Fatalf("failed to create temp file: %v", err) } err := HandleSELinuxContext(tempFile) if err != nil { t.Errorf("HandleSELinuxContext() on non-SELinux system returned error: %v", err) } } func TestHandleSELinuxContext_InvalidPath(t *testing.T) { // Skip on SELinux systems - this test is for non-SELinux behavior if _, err := exec.LookPath("getenforce"); err == nil { t.Skip("skipping on SELinux-enabled system") } // On non-SELinux systems, getenforce fails early so even invalid paths succeed err := HandleSELinuxContext("/nonexistent/path/binary") if err != nil { t.Errorf("HandleSELinuxContext() with invalid path on non-SELinux system returned error: %v", err) } } func TestTrySemanageRestorecon_NoTools(t *testing.T) { // Skip if semanage is available (we don't want to modify system SELinux policy) if _, err := exec.LookPath("semanage"); err == nil { t.Skip("skipping on system with semanage available") } // Should return false when semanage is not available result := trySemanageRestorecon("/some/path") if result { t.Error("trySemanageRestorecon() returned true when semanage is not available") } } ================================================ FILE: internal/hub/agent_connect.go ================================================ package hub import ( "context" "errors" "net" "net/http" "strings" "sync" "time" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/hub/ws" "github.com/blang/semver" "github.com/lxzan/gws" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" ) // agentConnectRequest holds information related to an agent's connection attempt. type agentConnectRequest struct { hub *Hub req *http.Request res http.ResponseWriter token string agentSemVer semver.Version // isUniversalToken is true if the token is a universal token. isUniversalToken bool // userId is the user ID associated with the universal token. userId string } // universalTokenMap stores active universal tokens and their associated user IDs. var universalTokenMap tokenMap type tokenMap struct { store *expirymap.ExpiryMap[string] once sync.Once } // getMap returns the expirymap, creating it if necessary. func (tm *tokenMap) GetMap() *expirymap.ExpiryMap[string] { tm.once.Do(func() { tm.store = expirymap.New[string](time.Hour) }) return tm.store } // handleAgentConnect is the HTTP handler for an agent's connection request. func (h *Hub) handleAgentConnect(e *core.RequestEvent) error { agentRequest := agentConnectRequest{req: e.Request, res: e.Response, hub: h} _ = agentRequest.agentConnect() return nil } // agentConnect validates agent credentials and upgrades the connection to a WebSocket. func (acr *agentConnectRequest) agentConnect() (err error) { var agentVersion string acr.token, agentVersion, err = acr.validateAgentHeaders(acr.req.Header) if err != nil { return acr.sendResponseError(acr.res, http.StatusBadRequest, "") } // Check if token is an active universal token acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token) if !acr.isUniversalToken { // Fallback: check for a permanent universal token stored in the DB if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil { if userID := rec.GetString("user"); userID != "" { acr.userId = userID acr.isUniversalToken = true } } } // Find matching fingerprint records for this token fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub) if len(fpRecords) == 0 && !acr.isUniversalToken { // Invalid token - no records found and not a universal token return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid token") } // Validate agent version acr.agentSemVer, err = semver.Parse(agentVersion) if err != nil { return acr.sendResponseError(acr.res, http.StatusUnauthorized, "Invalid agent version") } // Upgrade connection to WebSocket conn, err := ws.GetUpgrader().Upgrade(acr.res, acr.req) if err != nil { return acr.sendResponseError(acr.res, http.StatusInternalServerError, "WebSocket upgrade failed") } go acr.verifyWsConn(conn, fpRecords) return nil } // verifyWsConn verifies the WebSocket connection using the agent's fingerprint and // SSH key signature, then adds the system to the system manager. func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) { wsConn := ws.NewWsConnection(conn, acr.agentSemVer) // must set wsConn in connection store before the read loop conn.Session().Store("wsConn", wsConn) // make sure connection is closed if there is an error defer func() { if err != nil { wsConn.Close([]byte(err.Error())) } }() go conn.ReadLoop() signer, err := acr.hub.GetSSHKey("") if err != nil { return err } agentFingerprint, err := wsConn.GetFingerprint(context.Background(), acr.token, signer, acr.isUniversalToken) if err != nil { return err } // Find or create the appropriate system for this token and fingerprint fpRecord, err := acr.findOrCreateSystemForToken(fpRecords, agentFingerprint) if err != nil { return err } return acr.hub.sm.AddWebSocketSystem(fpRecord.SystemId, acr.agentSemVer, wsConn) } // validateAgentHeaders extracts and validates the token and agent version from HTTP headers. func (acr *agentConnectRequest) validateAgentHeaders(headers http.Header) (string, string, error) { token := headers.Get("X-Token") agentVersion := headers.Get("X-Beszel") if agentVersion == "" || token == "" || len(token) > 64 { return "", "", errors.New("") } return token, agentVersion, nil } // sendResponseError writes an HTTP error response. func (acr *agentConnectRequest) sendResponseError(res http.ResponseWriter, code int, message string) error { res.WriteHeader(code) if message != "" { res.Write([]byte(message)) } return nil } // getFingerprintRecordsByToken retrieves all fingerprint records associated with a given token. func getFingerprintRecordsByToken(token string, h *Hub) []ws.FingerprintRecord { var records []ws.FingerprintRecord // All will populate empty slice even on error _ = h.DB().NewQuery("SELECT id, system, fingerprint, token FROM fingerprints WHERE token = {:token}"). Bind(dbx.Params{ "token": token, }). All(&records) return records } // findOrCreateSystemForToken finds an existing system matching the token and fingerprint, // or creates a new one for a universal token. func (acr *agentConnectRequest) findOrCreateSystemForToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) { // No records - only valid for active universal tokens if len(fpRecords) == 0 { return acr.handleNoRecords(agentFingerprint) } // Single record - handle as regular token if len(fpRecords) == 1 && !acr.isUniversalToken { return acr.handleSingleRecord(fpRecords[0], agentFingerprint) } // Multiple records or universal token - look for matching fingerprint return acr.handleMultipleRecordsOrUniversalToken(fpRecords, agentFingerprint) } // handleNoRecords handles the case where no fingerprint records are found for a token. // A new system is created if the token is a valid universal token. func (acr *agentConnectRequest) handleNoRecords(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) { var fpRecord ws.FingerprintRecord if !acr.isUniversalToken || acr.userId == "" { return fpRecord, errors.New("no matching fingerprints") } return acr.createNewSystemForUniversalToken(agentFingerprint) } // handleSingleRecord handles the case with a single fingerprint record. It validates // the agent's fingerprint against the stored one, or sets it on first connect. func (acr *agentConnectRequest) handleSingleRecord(fpRecord ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) { // If no current fingerprint, update with new fingerprint (first time connecting) if fpRecord.Fingerprint == "" { if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil { return fpRecord, err } // Update the record with the fingerprint that was set fpRecord.Fingerprint = agentFingerprint.Fingerprint return fpRecord, nil } // Abort if fingerprint exists but doesn't match (different machine) if fpRecord.Fingerprint != agentFingerprint.Fingerprint { return fpRecord, errors.New("fingerprint mismatch") } return fpRecord, nil } // handleMultipleRecordsOrUniversalToken finds a matching fingerprint from multiple records. // If no match is found and the token is a universal token, a new system is created. func (acr *agentConnectRequest) handleMultipleRecordsOrUniversalToken(fpRecords []ws.FingerprintRecord, agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) { // Return existing record with matching fingerprint if found for i := range fpRecords { if fpRecords[i].Fingerprint == agentFingerprint.Fingerprint { return fpRecords[i], nil } } // No matching fingerprint record found, but it's // an active universal token so create a new system if acr.isUniversalToken { return acr.createNewSystemForUniversalToken(agentFingerprint) } return ws.FingerprintRecord{}, errors.New("fingerprint mismatch") } // createNewSystemForUniversalToken creates a new system and fingerprint record for a universal token. func (acr *agentConnectRequest) createNewSystemForUniversalToken(agentFingerprint common.FingerprintResponse) (ws.FingerprintRecord, error) { var fpRecord ws.FingerprintRecord if !acr.isUniversalToken || acr.userId == "" { return fpRecord, errors.New("invalid token") } fpRecord.Token = acr.token systemId, err := acr.createSystem(agentFingerprint) if err != nil { return fpRecord, err } fpRecord.SystemId = systemId // Set the fingerprint for the new system if err := acr.hub.SetFingerprint(&fpRecord, agentFingerprint.Fingerprint); err != nil { return fpRecord, err } // Update the record with the fingerprint that was set fpRecord.Fingerprint = agentFingerprint.Fingerprint return fpRecord, nil } // createSystem creates a new system record in the database using details from the agent. func (acr *agentConnectRequest) createSystem(agentFingerprint common.FingerprintResponse) (recordId string, err error) { systemsCollection, err := acr.hub.FindCachedCollectionByNameOrId("systems") if err != nil { return "", err } remoteAddr := getRealIP(acr.req) // separate port from address if agentFingerprint.Hostname == "" { agentFingerprint.Hostname = remoteAddr } if agentFingerprint.Port == "" { agentFingerprint.Port = "45876" } if agentFingerprint.Name == "" { agentFingerprint.Name = agentFingerprint.Hostname } // create new record systemRecord := core.NewRecord(systemsCollection) systemRecord.Set("name", agentFingerprint.Name) systemRecord.Set("host", remoteAddr) systemRecord.Set("port", agentFingerprint.Port) systemRecord.Set("users", []string{acr.userId}) return systemRecord.Id, acr.hub.Save(systemRecord) } // SetFingerprint creates or updates a fingerprint record in the database. func (h *Hub) SetFingerprint(fpRecord *ws.FingerprintRecord, fingerprint string) (err error) { // // can't use raw query here because it doesn't trigger SSE var record *core.Record switch fpRecord.Id { case "": // create new record for universal token collection, _ := h.FindCachedCollectionByNameOrId("fingerprints") record = core.NewRecord(collection) record.Set("system", fpRecord.SystemId) default: record, err = h.FindRecordById("fingerprints", fpRecord.Id) } if err != nil { return err } record.Set("token", fpRecord.Token) record.Set("fingerprint", fingerprint) return h.SaveNoValidate(record) } // getRealIP extracts the client's real IP address from request headers, // checking common proxy headers before falling back to the remote address. func getRealIP(r *http.Request) string { if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { return ip } if ip := r.Header.Get("X-Forwarded-For"); ip != "" { // X-Forwarded-For can contain a comma-separated list: "client_ip, proxy1, proxy2" // Take the first one ips := strings.Split(ip, ",") if len(ips) > 0 { return strings.TrimSpace(ips[0]) } } // Fallback to RemoteAddr ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return ip } ================================================ FILE: internal/hub/agent_connect_test.go ================================================ //go:build testing package hub import ( "crypto/ed25519" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/henrygd/beszel/agent" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/ws" "github.com/pocketbase/pocketbase/core" pbtests "github.com/pocketbase/pocketbase/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) // Helper function to create a test hub without import cycle func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) { testDataDir := t.TempDir() testApp, err := pbtests.NewTestApp(testDataDir) if err != nil { return nil, nil, err } return NewHub(testApp), testApp, nil } // cleanupTestHub stops background system goroutines before tearing down the app. func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) { if hub != nil { sm := hub.GetSystemManager() sm.RemoveAllSystems() // Give updater goroutines a brief window to observe cancellation before DB teardown. for range 20 { if sm.GetSystemCount() == 0 { break } runtime.Gosched() time.Sleep(5 * time.Millisecond) } time.Sleep(20 * time.Millisecond) } if testApp != nil { testApp.Cleanup() } } // Helper function to create a test record func createTestRecord(app core.App, collection string, data map[string]any) (*core.Record, error) { col, err := app.FindCachedCollectionByNameOrId(collection) if err != nil { return nil, err } record := core.NewRecord(col) for key, value := range data { record.Set(key, value) } return record, app.Save(record) } // Helper function to create a test user func createTestUser(app core.App) (*core.Record, error) { userRecord, err := createTestRecord(app, "users", map[string]any{ "email": "test@test.com", "password": "testtesttest", }) return userRecord, err } // TestValidateAgentHeaders tests the validateAgentHeaders function func TestValidateAgentHeaders(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) testCases := []struct { name string headers http.Header expectError bool expectedToken string expectedAgent string }{ { name: "valid headers", headers: http.Header{ "X-Token": []string{"valid-token-123"}, "X-Beszel": []string{"0.5.0"}, }, expectError: false, expectedToken: "valid-token-123", expectedAgent: "0.5.0", }, { name: "missing token", headers: http.Header{ "X-Beszel": []string{"0.5.0"}, }, expectError: true, }, { name: "missing agent version", headers: http.Header{ "X-Token": []string{"valid-token-123"}, }, expectError: true, }, { name: "empty token", headers: http.Header{ "X-Token": []string{""}, "X-Beszel": []string{"0.5.0"}, }, expectError: true, }, { name: "empty agent version", headers: http.Header{ "X-Token": []string{"valid-token-123"}, "X-Beszel": []string{""}, }, expectError: true, }, { name: "token too long", headers: http.Header{ "X-Token": []string{strings.Repeat("a", 65)}, "X-Beszel": []string{"0.5.0"}, }, expectError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { acr := &agentConnectRequest{hub: hub} token, agentVersion, err := acr.validateAgentHeaders(tc.headers) if tc.expectError { assert.Error(t, err) } else { require.NoError(t, err) assert.Equal(t, tc.expectedToken, token) assert.Equal(t, tc.expectedAgent, agentVersion) } }) } } // TestGetAllFingerprintRecordsByToken tests the getAllFingerprintRecordsByToken function func TestGetAllFingerprintRecordsByToken(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) // create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } // Create test data systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) if err != nil { t.Fatal(err) } fingerprintRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "test-token-123", "fingerprint": "test-fingerprint", }) for i := range 3 { systemRecord, _ := createTestRecord(testApp, "systems", map[string]any{ "name": fmt.Sprintf("test-system-%d", i), "host": "localhost", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "duplicate-token", "fingerprint": fmt.Sprintf("test-fingerprint-%d", i), }) } if err != nil { t.Fatal(err) } testCases := []struct { name string token string expectedId string expectLen int }{ { name: "valid token", token: "test-token-123", expectLen: 1, expectedId: fingerprintRecord.Id, }, { name: "invalid token", token: "invalid-token", expectLen: 0, }, { name: "empty token", token: "", expectLen: 0, }, { name: "duplicate token", token: "duplicate-token", expectLen: 3, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { records := getFingerprintRecordsByToken(tc.token, hub) require.Len(t, records, tc.expectLen) if tc.expectedId != "" { assert.Equal(t, tc.expectedId, records[0].Id) } }) } } // TestSetFingerprint tests the SetFingerprint function func TestSetFingerprint(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) // Create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) if err != nil { t.Fatal(err) } // Create fingerprint record fingerprintRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "test-token-123", "fingerprint": "", }) if err != nil { t.Fatal(err) } testCases := []struct { name string recordId string newFingerprint string expectError bool }{ { name: "successful fingerprint update", recordId: fingerprintRecord.Id, newFingerprint: "new-test-fingerprint", expectError: false, }, { name: "empty fingerprint", recordId: fingerprintRecord.Id, newFingerprint: "", expectError: false, }, { name: "invalid record ID", recordId: "invalid-id", newFingerprint: "fingerprint", expectError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { err := hub.SetFingerprint(&ws.FingerprintRecord{Id: tc.recordId, Token: "test-token-123"}, tc.newFingerprint) if tc.expectError { assert.Error(t, err) } else { require.NoError(t, err) // Verify fingerprint was updated updatedRecord, err := testApp.FindRecordById("fingerprints", tc.recordId) require.NoError(t, err) assert.Equal(t, tc.newFingerprint, updatedRecord.GetString("fingerprint")) } }) } } // TestCreateSystemFromAgentData tests the createSystemFromAgentData function func TestCreateSystemFromAgentData(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) // Create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } testCases := []struct { name string agentConnReq agentConnectRequest fingerprint common.FingerprintResponse expectError bool expectedName string expectedHost string expectedPort string expectedUsers []string }{ { name: "successful system creation with all fields", agentConnReq: agentConnectRequest{ hub: hub, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.0.1", }, }, fingerprint: common.FingerprintResponse{ Hostname: "test-server", Port: "8080", }, expectError: false, expectedName: "test-server", expectedHost: "192.168.0.1", // This will be the parsed IP from the mock request expectedPort: "8080", expectedUsers: []string{userRecord.Id}, }, { name: "system creation with default port", agentConnReq: agentConnectRequest{ hub: hub, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.0.1", }, }, fingerprint: common.FingerprintResponse{ Hostname: "default-port-server", Port: "", // Empty port should default to 45876 }, expectError: false, expectedName: "default-port-server", expectedHost: "192.168.0.1", // This will be the parsed IP from the mock request expectedPort: "45876", expectedUsers: []string{userRecord.Id}, }, { name: "system creation with empty hostname", agentConnReq: agentConnectRequest{ hub: hub, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.0.1", }, }, fingerprint: common.FingerprintResponse{ Hostname: "", Port: "9090", }, expectError: false, expectedName: "192.168.0.1", // Should fall back to host IP when hostname is empty expectedHost: "192.168.0.1", // This will be the parsed IP from the mock request expectedPort: "9090", expectedUsers: []string{userRecord.Id}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { recordId, err := tc.agentConnReq.createSystem(tc.fingerprint) if tc.expectError { assert.Error(t, err) return } require.NoError(t, err) assert.NotEmpty(t, recordId, "Record ID should not be empty") // Verify the created system record systemRecord, err := testApp.FindRecordById("systems", recordId) require.NoError(t, err) assert.Equal(t, tc.expectedName, systemRecord.GetString("name")) assert.Equal(t, tc.expectedHost, systemRecord.GetString("host")) assert.Equal(t, tc.expectedPort, systemRecord.GetString("port")) // Verify users array users := systemRecord.Get("users") assert.Equal(t, tc.expectedUsers, users) }) } } // TestUniversalTokenFlow tests the complete universal token authentication flow func TestUniversalTokenFlow(t *testing.T) { _, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(nil, testApp) // Create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } // Set up universal token in the token map universalToken := "universal-token-123" universalTokenMap.GetMap().Set(universalToken, userRecord.Id, time.Hour) testCases := []struct { name string token string expectUniversalAuth bool expectError bool description string }{ { name: "valid universal token", token: universalToken, expectUniversalAuth: true, expectError: false, description: "Should recognize valid universal token", }, { name: "invalid universal token", token: "invalid-universal-token", expectUniversalAuth: false, expectError: true, description: "Should reject invalid universal token", }, { name: "empty token", token: "", expectUniversalAuth: false, expectError: true, description: "Should reject empty token", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { acr := &agentConnectRequest{} acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(tc.token) if tc.expectError { assert.False(t, acr.isUniversalToken) assert.Empty(t, acr.userId) } else { assert.Equal(t, tc.expectUniversalAuth, acr.isUniversalToken) if tc.expectUniversalAuth { assert.Equal(t, userRecord.Id, acr.userId) } } }) } } // TestAgentConnect tests the agentConnect function with various scenarios func TestAgentConnect(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) // Create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) if err != nil { t.Fatal(err) } // Create fingerprint record testToken := "test-token-456" _, err = createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": testToken, "fingerprint": "", }) if err != nil { t.Fatal(err) } testCases := []struct { name string headers map[string]string expectedStatus int description string errorMessage string }{ { name: "missing token header", headers: map[string]string{ "X-Beszel": "0.5.0", }, expectedStatus: http.StatusBadRequest, description: "Should fail due to missing token", errorMessage: "", }, { name: "missing agent version header", headers: map[string]string{ "X-Token": testToken, }, expectedStatus: http.StatusBadRequest, description: "Should fail due to missing agent version", errorMessage: "", }, { name: "invalid token", headers: map[string]string{ "X-Token": "invalid-token", "X-Beszel": "0.5.0", }, expectedStatus: http.StatusUnauthorized, description: "Should fail due to invalid token", errorMessage: "Invalid token", }, { name: "invalid agent version", headers: map[string]string{ "X-Token": testToken, "X-Beszel": "0.5.0.0.0", }, expectedStatus: http.StatusUnauthorized, description: "Should fail due to invalid agent version", errorMessage: "Invalid agent version", }, { name: "valid headers but websocket upgrade will fail in test", headers: map[string]string{ "X-Token": testToken, "X-Beszel": "0.5.0", }, expectedStatus: http.StatusInternalServerError, description: "Should pass validation but fail at WebSocket upgrade due to test limitations", errorMessage: "WebSocket upgrade failed", }, { name: "Token too long", headers: map[string]string{"X-Token": strings.Repeat("a", 65), "X-Beszel": "0.5.0"}, expectedStatus: http.StatusBadRequest, description: "Should reject token exceeding 64 characters", errorMessage: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/api/beszel/agent-connect", nil) for key, value := range tc.headers { req.Header.Set(key, value) } recorder := httptest.NewRecorder() acr := &agentConnectRequest{ hub: hub, req: req, res: recorder, } err = acr.agentConnect() assert.Equal(t, tc.expectedStatus, recorder.Code, tc.description) assert.Equal(t, tc.errorMessage, recorder.Body.String(), tc.description) }) } } // TestSendResponseError tests the sendResponseError function func TestSendResponseError(t *testing.T) { testCases := []struct { name string statusCode int message string expectedStatus int expectedBody string }{ { name: "unauthorized error", statusCode: http.StatusUnauthorized, message: "Invalid token", expectedStatus: http.StatusUnauthorized, expectedBody: "Invalid token", }, { name: "bad request error", statusCode: http.StatusBadRequest, message: "Missing required header", expectedStatus: http.StatusBadRequest, expectedBody: "Missing required header", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { recorder := httptest.NewRecorder() acr := &agentConnectRequest{} acr.sendResponseError(recorder, tc.statusCode, tc.message) assert.Equal(t, tc.expectedStatus, recorder.Code) assert.Equal(t, tc.expectedBody, recorder.Body.String()) }) } } // TestHandleAgentConnect tests the HTTP handler func TestHandleAgentConnect(t *testing.T) { hub, testApp, err := createTestHub(t) if err != nil { t.Fatal(err) } defer cleanupTestHub(hub, testApp) // Create test user userRecord, err := createTestUser(testApp) if err != nil { t.Fatal(err) } // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) if err != nil { t.Fatal(err) } // Create fingerprint record testToken := "test-token-789" _, err = createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": testToken, "fingerprint": "", }) if err != nil { t.Fatal(err) } testCases := []struct { name string method string headers map[string]string expectedStatus int description string }{ { name: "GET with invalid token", method: "GET", headers: map[string]string{ "X-Token": "invalid", "X-Beszel": "0.5.0", }, expectedStatus: http.StatusUnauthorized, description: "Should reject invalid token", }, { name: "GET with valid token", method: "GET", headers: map[string]string{ "X-Token": testToken, "X-Beszel": "0.5.0", }, expectedStatus: http.StatusInternalServerError, // WebSocket upgrade fails in test description: "Should pass validation but fail at WebSocket upgrade", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.method, "/api/beszel/agent-connect", nil) for key, value := range tc.headers { req.Header.Set(key, value) } recorder := httptest.NewRecorder() acr := &agentConnectRequest{ hub: hub, req: req, res: recorder, } err = acr.agentConnect() assert.Equal(t, tc.expectedStatus, recorder.Code, tc.description) }) } } // TestAgentWebSocketIntegration tests WebSocket connection scenarios with an actual agent func TestAgentWebSocketIntegration(t *testing.T) { // Create hub and test app hub, testApp, err := createTestHub(t) require.NoError(t, err) defer cleanupTestHub(hub, testApp) // Get the hub's SSH key hubSigner, err := hub.GetSSHKey("") require.NoError(t, err) goodPubKey := hubSigner.PublicKey() // Generate bad key pair (should be rejected) _, badPrivKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) badPubKey, err := ssh.NewPublicKey(badPrivKey.Public().(ed25519.PublicKey)) require.NoError(t, err) // Create test user userRecord, err := createTestUser(testApp) require.NoError(t, err) // Create HTTP server with the actual API route ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/beszel/agent-connect" { acr := &agentConnectRequest{ hub: hub, req: r, res: w, } acr.agentConnect() } else { http.NotFound(w, r) } })) defer ts.Close() testCases := []struct { name string agentToken string // Token agent will send dbToken string // Token in database (empty means no record created) agentFingerprint string // Fingerprint agent will send (empty means agent generates its own) dbFingerprint string // Fingerprint in database agentSSHKey ssh.PublicKey expectConnection bool expectFingerprint string // "empty", "unchanged", or "updated" expectSystemStatus string description string }{ { name: "empty fingerprint - agent sets fingerprint on first connection", agentToken: "test-token-1", dbToken: "test-token-1", agentFingerprint: "agent-fingerprint-1", dbFingerprint: "", agentSSHKey: goodPubKey, expectConnection: true, expectFingerprint: "updated", expectSystemStatus: "up", description: "Agent should connect and set its fingerprint when DB fingerprint is empty", }, { name: "matching fingerprint should be accepted", agentToken: "test-token-2", dbToken: "test-token-2", agentFingerprint: "matching-fingerprint-123", dbFingerprint: "matching-fingerprint-123", agentSSHKey: goodPubKey, expectConnection: true, expectFingerprint: "unchanged", expectSystemStatus: "up", description: "Agent should connect when its fingerprint matches existing DB fingerprint", }, { name: "fingerprint mismatch should be rejected", agentToken: "test-token-3", dbToken: "test-token-3", agentFingerprint: "different-fingerprint-456", dbFingerprint: "original-fingerprint-123", agentSSHKey: goodPubKey, expectConnection: false, expectFingerprint: "unchanged", expectSystemStatus: "pending", description: "Agent should be rejected when its fingerprint doesn't match existing DB fingerprint", }, { name: "invalid token should be rejected", agentToken: "invalid-token-999", dbToken: "test-token-4", agentFingerprint: "matching-fingerprint-456", dbFingerprint: "matching-fingerprint-456", agentSSHKey: goodPubKey, expectConnection: false, expectFingerprint: "unchanged", expectSystemStatus: "pending", description: "Connection should fail when using invalid token", }, { // This is more for the agent side, but might as well test it here name: "wrong SSH key should be rejected", agentToken: "test-token-5", dbToken: "test-token-5", agentFingerprint: "matching-fingerprint-789", dbFingerprint: "matching-fingerprint-789", agentSSHKey: badPubKey, expectConnection: false, expectFingerprint: "unchanged", expectSystemStatus: "pending", description: "Connection should fail when agent uses wrong SSH key", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create test system with unique port for each test portNum := 45000 + len(tc.name) // Use name length to get unique port systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": fmt.Sprintf("test-system-%s", tc.name), "host": "localhost", "port": fmt.Sprintf("%d", portNum), "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Always create fingerprint record for this test's system fingerprintRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": tc.dbToken, "fingerprint": tc.dbFingerprint, }) require.NoError(t, err) // Create and configure agent agentDataDir := t.TempDir() // Set up agent fingerprint if specified err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte(tc.agentFingerprint), 0644) require.NoError(t, err) t.Logf("Pre-created fingerprint file for agent: %s", tc.agentFingerprint) testAgent, err := agent.NewAgent(agentDataDir) require.NoError(t, err) // Set up environment variables for the agent os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL) os.Setenv("BESZEL_AGENT_TOKEN", tc.agentToken) defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() // Start agent in background done := make(chan error, 1) go func() { serverOptions := agent.ServerOptions{ Network: "tcp", Addr: fmt.Sprintf("127.0.0.1:%d", portNum), Keys: []ssh.PublicKey{tc.agentSSHKey}, } done <- testAgent.Start(serverOptions) }() // Wait for connection result maxWait := 2 * time.Second time.Sleep(40 * time.Millisecond) checkInterval := 20 * time.Millisecond timeout := time.After(maxWait) ticker := time.Tick(checkInterval) connectionManager := testAgent.GetConnectionManager() connectionResult := false for { select { case <-timeout: // Timeout reached if tc.expectConnection { t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State) } else { t.Logf("Connection properly rejected (timeout) - agent state: %d", connectionManager.State) } connectionResult = false case <-ticker: if connectionManager.State == agent.WebSocketConnected { if tc.expectConnection { t.Logf("WebSocket connection successful - agent state: %d", connectionManager.State) connectionResult = true } else { t.Errorf("Unexpected: Connection succeeded when it should have been rejected") return } } case err := <-done: if err != nil { if !tc.expectConnection { t.Logf("Agent connection properly rejected: %v", err) connectionResult = false } else { t.Fatalf("Agent failed to start: %v", err) } } } // Break if we got the expected result or timed out if connectionResult == tc.expectConnection || connectionResult { break } } time.Sleep(20 * time.Millisecond) // Verify fingerprint state by re-reading the specific record updatedFingerprintRecord, err := testApp.FindRecordById("fingerprints", fingerprintRecord.Id) require.NoError(t, err) finalFingerprint := updatedFingerprintRecord.GetString("fingerprint") switch tc.expectFingerprint { case "empty": assert.Empty(t, finalFingerprint, "Fingerprint should be empty") case "unchanged": assert.Equal(t, tc.dbFingerprint, finalFingerprint, "Fingerprint should not change when connection is rejected") case "updated": if tc.dbFingerprint == "" { assert.NotEmpty(t, finalFingerprint, "Fingerprint should be updated after successful connection") } else { assert.NotEqual(t, tc.dbFingerprint, finalFingerprint, "Fingerprint should be updated after successful connection") } } // Verify system status updatedSystemRecord, err := testApp.FindRecordById("systems", systemRecord.Id) require.NoError(t, err) status := updatedSystemRecord.GetString("status") assert.Equal(t, tc.expectSystemStatus, status, "System status should match expected value") t.Logf("%s - System status: %s, Fingerprint: %s", tc.description, status, finalFingerprint) }) } } // TestMultipleSystemsWithSameUniversalToken tests that multiple systems can share the same universal token func TestMultipleSystemsWithSameUniversalToken(t *testing.T) { // Create hub and test app hub, testApp, err := createTestHub(t) require.NoError(t, err) defer cleanupTestHub(hub, testApp) // Get the hub's SSH key hubSigner, err := hub.GetSSHKey("") require.NoError(t, err) goodPubKey := hubSigner.PublicKey() // Create test user userRecord, err := createTestUser(testApp) require.NoError(t, err) // Set up universal token in the token map universalToken := "shared-universal-token-123" universalTokenMap.GetMap().Set(universalToken, userRecord.Id, time.Hour) // Create HTTP server with the actual API route ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/beszel/agent-connect" { acr := &agentConnectRequest{ hub: hub, req: r, res: w, } acr.agentConnect() } else { http.NotFound(w, r) } })) defer ts.Close() // Test scenarios for universal tokens testCases := []struct { name string agentFingerprint string expectConnection bool expectSystemStatus string expectNewSystem bool // Whether we expect a new system to be created description string }{ { name: "first system with universal token", agentFingerprint: "system-1-fingerprint", expectConnection: true, expectSystemStatus: "up", expectNewSystem: true, description: "First system should create a new system", }, { name: "same system reconnecting with same fingerprint", agentFingerprint: "system-1-fingerprint", // Same fingerprint as first expectConnection: true, expectSystemStatus: "up", expectNewSystem: false, // Should reuse existing system description: "Same system should reuse existing system record", }, { name: "different system with same universal token", agentFingerprint: "system-2-fingerprint", // Different fingerprint expectConnection: true, expectSystemStatus: "up", expectNewSystem: true, // Should create new system description: "Different system should create a new system record", }, } var systemCount int for i, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create unique port for each test portNum := 46000 + i // Create and configure agent agentDataDir := t.TempDir() // Set up agent fingerprint err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte(tc.agentFingerprint), 0644) require.NoError(t, err) testAgent, err := agent.NewAgent(agentDataDir) require.NoError(t, err) // Set up environment variables for the agent os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL) os.Setenv("BESZEL_AGENT_TOKEN", universalToken) defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() // Count systems before connection systemsBefore, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id}) require.NoError(t, err) systemsBeforeCount := len(systemsBefore) // Start agent in background done := make(chan error, 1) go func() { serverOptions := agent.ServerOptions{ Network: "tcp", Addr: fmt.Sprintf("127.0.0.1:%d", portNum), Keys: []ssh.PublicKey{goodPubKey}, } done <- testAgent.Start(serverOptions) }() // Wait for connection result maxWait := 2 * time.Second time.Sleep(20 * time.Millisecond) checkInterval := 20 * time.Millisecond timeout := time.After(maxWait) ticker := time.Tick(checkInterval) connectionManager := testAgent.GetConnectionManager() connectionResult := false for { select { case <-timeout: if tc.expectConnection { t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State) } else { t.Logf("Connection properly rejected (timeout) - agent state: %d", connectionManager.State) } connectionResult = false case <-ticker: if connectionManager.State == agent.WebSocketConnected { if tc.expectConnection { t.Logf("WebSocket connection successful - agent state: %d", connectionManager.State) connectionResult = true } else { t.Errorf("Unexpected: Connection succeeded when it should have been rejected") return } } case err := <-done: if err != nil { if !tc.expectConnection { t.Logf("Agent connection properly rejected: %v", err) connectionResult = false } else { t.Fatalf("Agent failed to start: %v", err) } } } if connectionResult == tc.expectConnection || connectionResult { break } } // Verify system creation/reuse behavior if tc.expectConnection { // Count systems after connection systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id}) require.NoError(t, err) systemsAfterCount := len(systemsAfter) if tc.expectNewSystem { // Should have created a new system systemCount++ assert.Equal(t, systemsBeforeCount+1, systemsAfterCount, "Should have created a new system") assert.Equal(t, systemCount, systemsAfterCount, "Total system count should match expected") } else { // Should have reused existing system assert.Equal(t, systemsBeforeCount, systemsAfterCount, "Should not have created a new system") assert.Equal(t, systemCount, systemsAfterCount, "Total system count should remain the same") } time.Sleep(20 * time.Millisecond) // Verify that a fingerprint record exists for this fingerprint fingerprints, err := testApp.FindRecordsByFilter("fingerprints", "token = {:token} && fingerprint = {:fingerprint}", "", -1, 0, map[string]any{ "token": universalToken, "fingerprint": tc.agentFingerprint, }) require.NoError(t, err) require.Len(t, fingerprints, 1, "Should have exactly one fingerprint record for this token+fingerprint combination") fingerprint := fingerprints[0] assert.Equal(t, universalToken, fingerprint.GetString("token"), "Fingerprint should have the universal token") assert.Equal(t, tc.agentFingerprint, fingerprint.GetString("fingerprint"), "Fingerprint should match agent's fingerprint") // Verify system status systemId := fingerprint.GetString("system") system, err := testApp.FindRecordById("systems", systemId) require.NoError(t, err) status := system.GetString("status") assert.Equal(t, tc.expectSystemStatus, status, "System status should match expected value") t.Logf("%s - System ID: %s, Status: %s, New System: %v", tc.description, systemId, status, tc.expectNewSystem) } }) } } // TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB // (universal_tokens collection) is accepted for agent self-registration even if it is not // present in the in-memory universalTokenMap. func TestPermanentUniversalTokenFromDB(t *testing.T) { // Create hub and test app hub, testApp, err := createTestHub(t) require.NoError(t, err) defer cleanupTestHub(hub, testApp) // Get the hub's SSH key hubSigner, err := hub.GetSSHKey("") require.NoError(t, err) goodPubKey := hubSigner.PublicKey() // Create test user userRecord, err := createTestUser(testApp) require.NoError(t, err) // Create a permanent universal token record in the DB (do NOT add it to universalTokenMap) universalToken := "db-universal-token-123" _, err = createTestRecord(testApp, "universal_tokens", map[string]any{ "user": userRecord.Id, "token": universalToken, }) require.NoError(t, err) // Create HTTP server with the actual API route ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/api/beszel/agent-connect" { acr := &agentConnectRequest{ hub: hub, req: r, res: w, } acr.agentConnect() } else { http.NotFound(w, r) } })) defer ts.Close() // Create and configure agent agentDataDir := t.TempDir() err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644) require.NoError(t, err) testAgent, err := agent.NewAgent(agentDataDir) require.NoError(t, err) // Set up environment variables for the agent os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL) os.Setenv("BESZEL_AGENT_TOKEN", universalToken) defer func() { os.Unsetenv("BESZEL_AGENT_HUB_URL") os.Unsetenv("BESZEL_AGENT_TOKEN") }() // Start agent in background done := make(chan error, 1) go func() { serverOptions := agent.ServerOptions{ Network: "tcp", Addr: "127.0.0.1:46050", Keys: []ssh.PublicKey{goodPubKey}, } done <- testAgent.Start(serverOptions) }() // Wait for connection result maxWait := 2 * time.Second time.Sleep(20 * time.Millisecond) checkInterval := 20 * time.Millisecond timeout := time.After(maxWait) ticker := time.Tick(checkInterval) connectionManager := testAgent.GetConnectionManager() for { select { case <-timeout: t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State) case <-ticker: if connectionManager.State == agent.WebSocketConnected { // Success goto verify } case err := <-done: // If Start returns early, treat it as failure if err != nil { t.Fatalf("Agent failed to start/connect: %v", err) } } } verify: // Verify that a system was created for the user (self-registration path) systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id}) require.NoError(t, err) require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token") } // TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function func TestFindOrCreateSystemForToken(t *testing.T) { hub, testApp, err := createTestHub(t) require.NoError(t, err) defer cleanupTestHub(hub, testApp) // Create test user userRecord, err := createTestUser(testApp) require.NoError(t, err) type testCase struct { name string setup func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) agentFingerprint common.FingerprintResponse expectError bool expectNewSystem bool expectedFingerprint string description string } testCases := []testCase{ { name: "universal token - existing fingerprint match", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "existing-system", "host": "192.168.1.100", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint record fpRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "universal-token-123", "fingerprint": "existing-fingerprint", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "universal-token-123", isUniversalToken: true, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.1.100", }, } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord.Id, SystemId: systemRecord.Id, Fingerprint: "existing-fingerprint", Token: "universal-token-123", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "existing-fingerprint", Hostname: "test-host", Port: "8080", }, expectError: false, expectNewSystem: false, expectedFingerprint: "existing-fingerprint", description: "Should reuse existing system with matching fingerprint", }, { name: "universal token - new fingerprint", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "existing-system-2", "host": "192.168.1.101", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint record fpRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "universal-token-123", "fingerprint": "existing-fingerprint", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "universal-token-123", isUniversalToken: true, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.1.200", }, } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord.Id, SystemId: systemRecord.Id, Fingerprint: "existing-fingerprint", Token: "universal-token-123", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "new-fingerprint", Hostname: "new-host", Port: "9090", }, expectError: false, expectNewSystem: true, expectedFingerprint: "new-fingerprint", description: "Should create new system with different fingerprint", }, { name: "universal token - no existing records", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { acr := agentConnectRequest{ hub: hub, token: "universal-token-456", isUniversalToken: true, userId: userRecord.Id, req: &http.Request{ RemoteAddr: "192.168.1.300", }, } fpRecords := []ws.FingerprintRecord{} return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "first-fingerprint", Hostname: "first-host", Port: "7070", }, expectError: false, expectNewSystem: true, expectedFingerprint: "first-fingerprint", description: "Should create new system when no existing records", }, { name: "regular token - empty fingerprint", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "regular-system", "host": "192.168.1.200", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint record with empty fingerprint fpRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "regular-token-123", "fingerprint": "", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "regular-token-123", isUniversalToken: false, } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord.Id, SystemId: systemRecord.Id, Fingerprint: "", Token: "regular-token-123", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "agent-fingerprint", Hostname: "agent-host", Port: "6060", }, expectError: false, expectNewSystem: false, expectedFingerprint: "agent-fingerprint", description: "Should update empty fingerprint for regular token", }, { name: "regular token - fingerprint mismatch", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "regular-system-2", "host": "192.168.1.250", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint record with different fingerprint fpRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "regular-token-456", "fingerprint": "different-fingerprint", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "regular-token-456", isUniversalToken: false, } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord.Id, SystemId: systemRecord.Id, Fingerprint: "different-fingerprint", Token: "regular-token-456", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "agent-fingerprint", Hostname: "agent-host", Port: "5050", }, expectError: true, description: "Should reject fingerprint mismatch for regular token", }, { name: "universal token - missing user ID", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { acr := agentConnectRequest{ hub: hub, token: "universal-token-789", isUniversalToken: true, userId: "", // Missing user ID req: &http.Request{ RemoteAddr: "192.168.1.400", }, } fpRecords := []ws.FingerprintRecord{} return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "some-fingerprint", Hostname: "some-host", Port: "4040", }, expectError: true, description: "Should reject universal token without user ID", }, { name: "expired universal token - matching fingerprint", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test systems systemRecord1, err := createTestRecord(testApp, "systems", map[string]any{ "name": "expired-system-1", "host": "192.168.1.500", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) systemRecord2, err := createTestRecord(testApp, "systems", map[string]any{ "name": "expired-system-2", "host": "192.168.1.501", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint records fpRecord1, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord1.Id, "token": "expired-universal-token-123", "fingerprint": "expired-fingerprint-1", }) require.NoError(t, err) fpRecord2, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord2.Id, "token": "expired-universal-token-123", "fingerprint": "expired-fingerprint-2", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "expired-universal-token-123", isUniversalToken: false, // Token is no longer active userId: "", // No user ID since token is expired } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord1.Id, SystemId: systemRecord1.Id, Fingerprint: "expired-fingerprint-1", Token: "expired-universal-token-123", }, { Id: fpRecord2.Id, SystemId: systemRecord2.Id, Fingerprint: "expired-fingerprint-2", Token: "expired-universal-token-123", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "expired-fingerprint-1", // Matches first record Hostname: "expired-host", Port: "3030", }, expectError: false, expectNewSystem: false, expectedFingerprint: "expired-fingerprint-1", description: "Should allow connection with expired universal token if fingerprint matches", }, { name: "expired universal token - no matching fingerprint", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { // Create test system systemRecord, err := createTestRecord(testApp, "systems", map[string]any{ "name": "expired-system-3", "host": "192.168.1.600", "port": "45876", "status": "pending", "users": []string{userRecord.Id}, }) require.NoError(t, err) // Create fingerprint record fpRecord, err := createTestRecord(testApp, "fingerprints", map[string]any{ "system": systemRecord.Id, "token": "expired-universal-token-456", "fingerprint": "expired-fingerprint-3", }) require.NoError(t, err) acr := agentConnectRequest{ hub: hub, token: "expired-universal-token-456", isUniversalToken: false, // Token is no longer active userId: "", // No user ID since token is expired req: &http.Request{ RemoteAddr: "192.168.1.600", }, } fpRecords := []ws.FingerprintRecord{ { Id: fpRecord.Id, SystemId: systemRecord.Id, Fingerprint: "expired-fingerprint-3", Token: "expired-universal-token-456", }, } return acr, fpRecords }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "different-fingerprint", // Doesn't match any existing record Hostname: "different-host", Port: "2020", }, expectError: true, description: "Should reject connection with expired universal token if no fingerprint matches", }, { name: "regular token - no existing records", setup: func(t *testing.T, hub *Hub, testApp *pbtests.TestApp, userRecord *core.Record) (agentConnectRequest, []ws.FingerprintRecord) { acr := agentConnectRequest{ hub: hub, token: "regular-token-no-record", isUniversalToken: false, } return acr, []ws.FingerprintRecord{} }, agentFingerprint: common.FingerprintResponse{ Fingerprint: "some-fingerprint", }, expectError: true, description: "Should reject regular token with no fingerprint record", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { acr, fpRecords := tc.setup(t, hub, testApp, userRecord) result, err := acr.findOrCreateSystemForToken(fpRecords, tc.agentFingerprint) if tc.expectError { assert.Error(t, err, tc.description) return } require.NoError(t, err, tc.description) // Verify expected fingerprint if tc.expectedFingerprint != "" { assert.Equal(t, tc.expectedFingerprint, result.Fingerprint, "Fingerprint should match expected") } // For new systems, verify they were actually created if tc.expectNewSystem { assert.NotEmpty(t, result.SystemId, "New system should have a system ID") // Verify system was created in database system, err := testApp.FindRecordById("systems", result.SystemId) require.NoError(t, err, "New system should exist in database") // Verify system properties assert.Equal(t, tc.agentFingerprint.Hostname, system.GetString("name"), "System name should match hostname") assert.Equal(t, getRealIP(acr.req), system.GetString("host"), "System host should match remote address") assert.Equal(t, tc.agentFingerprint.Port, system.GetString("port"), "System port should match agent port") assert.Equal(t, []string{acr.userId}, system.Get("users"), "System users should match") } t.Logf("%s - Result: SystemId=%s, Fingerprint=%s", tc.description, result.SystemId, result.Fingerprint) }) } } // TestGetRealIP tests the getRealIP function func TestGetRealIP(t *testing.T) { testCases := []struct { name string headers map[string]string remoteAddr string expectedIP string }{ { name: "CF-Connecting-IP header", headers: map[string]string{"CF-Connecting-IP": "192.168.1.1"}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.1", }, { name: "X-Forwarded-For header with single IP", headers: map[string]string{"X-Forwarded-For": "192.168.1.2"}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.2", }, { name: "X-Forwarded-For header with multiple IPs", headers: map[string]string{"X-Forwarded-For": "192.168.1.3, 10.0.0.1, 172.16.0.1"}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.3", }, { name: "X-Forwarded-For header with spaces", headers: map[string]string{"X-Forwarded-For": " 192.168.1.4 "}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.4", }, { name: "No headers, fallback to RemoteAddr with port", headers: map[string]string{}, remoteAddr: "192.168.1.5:54321", expectedIP: "192.168.1.5", }, { name: "No headers, fallback to RemoteAddr without port", headers: map[string]string{}, remoteAddr: "192.168.1.6", expectedIP: "192.168.1.6", }, { name: "Both headers present, CF takes precedence", headers: map[string]string{"CF-Connecting-IP": "192.168.1.1", "X-Forwarded-For": "192.168.1.2"}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.1", }, { name: "X-Forwarded-For present, takes precedence over RemoteAddr", headers: map[string]string{"X-Forwarded-For": "192.168.1.2"}, remoteAddr: "192.168.1.5:54321", expectedIP: "192.168.1.2", }, { name: "Empty X-Forwarded-For, fallback to RemoteAddr", headers: map[string]string{"X-Forwarded-For": ""}, remoteAddr: "192.168.1.7:12345", expectedIP: "192.168.1.7", }, { name: "Empty CF-Connecting-IP, fallback to X-Forwarded-For", headers: map[string]string{"CF-Connecting-IP": "", "X-Forwarded-For": "192.168.1.8"}, remoteAddr: "127.0.0.1:12345", expectedIP: "192.168.1.8", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) for key, value := range tc.headers { req.Header.Set(key, value) } req.RemoteAddr = tc.remoteAddr ip := getRealIP(req) assert.Equal(t, tc.expectedIP, ip) }) } } ================================================ FILE: internal/hub/config/config.go ================================================ // Package config provides functions for syncing systems with the config.yml file package config import ( "fmt" "log" "os" "path/filepath" "github.com/google/uuid" "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/spf13/cast" "gopkg.in/yaml.v3" ) type config struct { Systems []systemConfig `yaml:"systems"` } type systemConfig struct { Name string `yaml:"name"` Host string `yaml:"host"` Port uint16 `yaml:"port,omitempty"` Token string `yaml:"token,omitempty"` Users []string `yaml:"users"` } // Syncs systems with the config.yml file func SyncSystems(e *core.ServeEvent) error { h := e.App configPath := filepath.Join(h.DataDir(), "config.yml") configData, err := os.ReadFile(configPath) if err != nil { return nil } var config config err = yaml.Unmarshal(configData, &config) if err != nil { return fmt.Errorf("failed to parse config.yml: %v", err) } if len(config.Systems) == 0 { log.Println("No systems defined in config.yml.") return nil } var firstUser *core.Record // Create a map of email to user ID userEmailToID := make(map[string]string) users, err := h.FindAllRecords("users", dbx.NewExp("id != ''")) if err != nil { return err } if len(users) > 0 { firstUser = users[0] for _, user := range users { userEmailToID[user.GetString("email")] = user.Id } } // add default settings for systems if not defined in config for i := range config.Systems { system := &config.Systems[i] if system.Port == 0 { system.Port = 45876 } if len(users) > 0 && len(system.Users) == 0 { // default to first user if none are defined system.Users = []string{firstUser.Id} } else { // Convert email addresses to user IDs userIDs := make([]string, 0, len(system.Users)) for _, email := range system.Users { if id, ok := userEmailToID[email]; ok { userIDs = append(userIDs, id) } else { log.Printf("User %s not found", email) } } system.Users = userIDs } } // Get existing systems existingSystems, err := h.FindAllRecords("systems", dbx.NewExp("id != ''")) if err != nil { return err } // Create a map of existing systems existingSystemsMap := make(map[string]*core.Record) for _, system := range existingSystems { key := system.GetString("name") + system.GetString("host") + system.GetString("port") existingSystemsMap[key] = system } // Process systems from config for _, sysConfig := range config.Systems { key := sysConfig.Name + sysConfig.Host + cast.ToString(sysConfig.Port) if existingSystem, ok := existingSystemsMap[key]; ok { // Update existing system existingSystem.Set("name", sysConfig.Name) existingSystem.Set("users", sysConfig.Users) existingSystem.Set("port", sysConfig.Port) if err := h.Save(existingSystem); err != nil { return err } // Only update token if one is specified in config, otherwise preserve existing token if sysConfig.Token != "" { if err := updateFingerprintToken(h, existingSystem.Id, sysConfig.Token); err != nil { return err } } delete(existingSystemsMap, key) } else { // Create new system systemsCollection, err := h.FindCollectionByNameOrId("systems") if err != nil { return fmt.Errorf("failed to find systems collection: %v", err) } newSystem := core.NewRecord(systemsCollection) newSystem.Set("name", sysConfig.Name) newSystem.Set("host", sysConfig.Host) newSystem.Set("port", sysConfig.Port) newSystem.Set("users", sysConfig.Users) newSystem.Set("info", system.Info{}) newSystem.Set("status", "pending") if err := h.Save(newSystem); err != nil { return fmt.Errorf("failed to create new system: %v", err) } // For new systems, generate token if not provided token := sysConfig.Token if token == "" { token = uuid.New().String() } // Create fingerprint record for new system if err := createFingerprintRecord(h, newSystem.Id, token); err != nil { return err } } } // Delete systems not in config (and their fingerprint records will cascade delete) for _, system := range existingSystemsMap { if err := h.Delete(system); err != nil { return err } } log.Println("Systems synced with config.yml") return nil } // Generates content for the config.yml file as a YAML string func generateYAML(h core.App) (string, error) { // Fetch all systems from the database systems, err := h.FindRecordsByFilter("systems", "id != ''", "name", -1, 0) if err != nil { return "", err } // Create a Config struct to hold the data config := config{ Systems: make([]systemConfig, 0, len(systems)), } // Fetch all users at once allUserIDs := make([]string, 0) for _, system := range systems { allUserIDs = append(allUserIDs, system.GetStringSlice("users")...) } userEmailMap, err := getUserEmailMap(h, allUserIDs) if err != nil { return "", err } // Fetch all fingerprint records to get tokens type fingerprintData struct { ID string `db:"id"` System string `db:"system"` Token string `db:"token"` } var fingerprints []fingerprintData err = h.DB().NewQuery("SELECT id, system, token FROM fingerprints").All(&fingerprints) if err != nil { return "", err } // Create a map of system ID to token systemTokenMap := make(map[string]string) for _, fingerprint := range fingerprints { systemTokenMap[fingerprint.System] = fingerprint.Token } // Populate the Config struct with system data for _, system := range systems { userIDs := system.GetStringSlice("users") userEmails := make([]string, 0, len(userIDs)) for _, userID := range userIDs { if email, ok := userEmailMap[userID]; ok { userEmails = append(userEmails, email) } } sysConfig := systemConfig{ Name: system.GetString("name"), Host: system.GetString("host"), Port: cast.ToUint16(system.Get("port")), Users: userEmails, Token: systemTokenMap[system.Id], } config.Systems = append(config.Systems, sysConfig) } // Marshal the Config struct to YAML yamlData, err := yaml.Marshal(&config) if err != nil { return "", err } // Add a header to the YAML yamlData = append([]byte("# Values for port, users, and token are optional.\n# Defaults are port 45876, the first created user, and a generated UUID token.\n\n"), yamlData...) return string(yamlData), nil } // New helper function to get a map of user IDs to emails func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) { users, err := h.FindRecordsByIds("users", userIDs) if err != nil { return nil, err } userEmailMap := make(map[string]string, len(users)) for _, user := range users { userEmailMap[user.Id] = user.GetString("email") } return userEmailMap, nil } // Helper function to update or create fingerprint token for an existing system func updateFingerprintToken(app core.App, systemID, token string) error { // Try to find existing fingerprint record fingerprint, err := app.FindFirstRecordByFilter("fingerprints", "system = {:system}", dbx.Params{"system": systemID}) if err != nil { // If no fingerprint record exists, create one return createFingerprintRecord(app, systemID, token) } // Update existing fingerprint record with new token (keep existing fingerprint) fingerprint.Set("token", token) return app.Save(fingerprint) } // Helper function to create a new fingerprint record for a system func createFingerprintRecord(app core.App, systemID, token string) error { fingerprintsCollection, err := app.FindCollectionByNameOrId("fingerprints") if err != nil { return fmt.Errorf("failed to find fingerprints collection: %v", err) } newFingerprint := core.NewRecord(fingerprintsCollection) newFingerprint.Set("system", systemID) newFingerprint.Set("token", token) newFingerprint.Set("fingerprint", "") // Empty fingerprint, will be set on first connection return app.Save(newFingerprint) } // Returns the current config.yml file as a JSON object func GetYamlConfig(e *core.RequestEvent) error { if e.Auth.GetString("role") != "admin" { return e.ForbiddenError("Requires admin role", nil) } configContent, err := generateYAML(e.App) if err != nil { return err } return e.JSON(200, map[string]string{"config": configContent}) } ================================================ FILE: internal/hub/config/config_test.go ================================================ //go:build testing package config_test import ( "os" "path/filepath" "testing" "github.com/henrygd/beszel/internal/tests" "github.com/henrygd/beszel/internal/hub/config" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) // Config struct for testing (copied from config package since it's not exported) type testConfig struct { Systems []testSystemConfig `yaml:"systems"` } type testSystemConfig struct { Name string `yaml:"name"` Host string `yaml:"host"` Port uint16 `yaml:"port,omitempty"` Users []string `yaml:"users"` Token string `yaml:"token,omitempty"` } // Helper function to create a test system for config tests // func createConfigTestSystem(app core.App, name, host string, port uint16, userIDs []string) (*core.Record, error) { // systemCollection, err := app.FindCollectionByNameOrId("systems") // if err != nil { // return nil, err // } // system := core.NewRecord(systemCollection) // system.Set("name", name) // system.Set("host", host) // system.Set("port", port) // system.Set("users", userIDs) // system.Set("status", "pending") // return system, app.Save(system) // } // Helper function to create a fingerprint record func createConfigTestFingerprint(app core.App, systemID, token, fingerprint string) (*core.Record, error) { fingerprintCollection, err := app.FindCollectionByNameOrId("fingerprints") if err != nil { return nil, err } fp := core.NewRecord(fingerprintCollection) fp.Set("system", systemID) fp.Set("token", token) fp.Set("fingerprint", fingerprint) return fp, app.Save(fp) } // TestConfigSyncWithTokens tests the config.SyncSystems function with various token scenarios func TestConfigSyncWithTokens(t *testing.T) { testHub, err := tests.NewTestHub() require.NoError(t, err) defer testHub.Cleanup() // Create test user user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest") require.NoError(t, err) testCases := []struct { name string setupFunc func() (string, *core.Record, *core.Record) // Returns: existing token, system record, fingerprint record configYAML string expectToken string // Expected token after sync description string }{ { name: "new system with token in config", setupFunc: func() (string, *core.Record, *core.Record) { return "", nil, nil // No existing system }, configYAML: `systems: - name: "new-server" host: "new.example.com" port: 45876 users: - "admin@example.com" token: "explicit-token-123"`, expectToken: "explicit-token-123", description: "New system should use token from config", }, { name: "existing system without token in config (preserve existing)", setupFunc: func() (string, *core.Record, *core.Record) { // Create existing system and fingerprint system, err := tests.CreateRecord(testHub.App, "systems", map[string]any{ "name": "preserve-server", "host": "preserve.example.com", "port": 45876, "users": []string{user.Id}, }) require.NoError(t, err) fingerprint, err := createConfigTestFingerprint(testHub.App, system.Id, "preserve-token-999", "preserve-fingerprint") require.NoError(t, err) return "preserve-token-999", system, fingerprint }, configYAML: `systems: - name: "preserve-server" host: "preserve.example.com" port: 45876 users: - "admin@example.com"`, expectToken: "preserve-token-999", description: "Existing system should preserve original token when config doesn't specify one", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Setup test data _, existingSystem, existingFingerprint := tc.setupFunc() // Write config file configPath := filepath.Join(testHub.DataDir(), "config.yml") err := os.WriteFile(configPath, []byte(tc.configYAML), 0644) require.NoError(t, err) // Create serve event and sync event := &core.ServeEvent{App: testHub.App} err = config.SyncSystems(event) require.NoError(t, err) // Parse the config to get the system name for verification var configData testConfig err = yaml.Unmarshal([]byte(tc.configYAML), &configData) require.NoError(t, err) require.Len(t, configData.Systems, 1) systemName := configData.Systems[0].Name // Find the system after sync systems, err := testHub.FindRecordsByFilter("systems", "name = {:name}", "", -1, 0, map[string]any{"name": systemName}) require.NoError(t, err) require.Len(t, systems, 1) system := systems[0] // Find the fingerprint record fingerprints, err := testHub.FindRecordsByFilter("fingerprints", "system = {:system}", "", -1, 0, map[string]any{"system": system.Id}) require.NoError(t, err) require.Len(t, fingerprints, 1) fingerprint := fingerprints[0] // Verify token actualToken := fingerprint.GetString("token") if tc.expectToken == "" { // For generated tokens, just verify it's not empty and is a valid UUID format assert.NotEmpty(t, actualToken, tc.description) assert.Len(t, actualToken, 36, "Generated token should be UUID format") // UUID length } else { assert.Equal(t, tc.expectToken, actualToken, tc.description) } // For existing systems, verify fingerprint is preserved if existingFingerprint != nil { actualFingerprint := fingerprint.GetString("fingerprint") expectedFingerprint := existingFingerprint.GetString("fingerprint") assert.Equal(t, expectedFingerprint, actualFingerprint, "Fingerprint should be preserved") } // Cleanup for next test if existingSystem != nil { testHub.Delete(existingSystem) } if existingFingerprint != nil { testHub.Delete(existingFingerprint) } // Clean up the new records testHub.Delete(system) testHub.Delete(fingerprint) }) } } // TestConfigMigrationScenario tests the specific migration scenario mentioned in the discussion func TestConfigMigrationScenario(t *testing.T) { testHub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer testHub.Cleanup() // Create test user user, err := tests.CreateUser(testHub.App, "admin@example.com", "testtesttest") require.NoError(t, err) // Simulate migration scenario: system exists with token from migration existingSystem, err := tests.CreateRecord(testHub.App, "systems", map[string]any{ "name": "migrated-server", "host": "migrated.example.com", "port": 45876, "users": []string{user.Id}, }) require.NoError(t, err) migrationToken := "migration-generated-token-123" existingFingerprint, err := createConfigTestFingerprint(testHub.App, existingSystem.Id, migrationToken, "existing-fingerprint-from-agent") require.NoError(t, err) // User exports config BEFORE this update (so no token field in YAML) oldConfigYAML := `systems: - name: "migrated-server" host: "migrated.example.com" port: 45876 users: - "admin@example.com"` // Write old config file and import configPath := filepath.Join(testHub.DataDir(), "config.yml") err = os.WriteFile(configPath, []byte(oldConfigYAML), 0644) require.NoError(t, err) event := &core.ServeEvent{App: testHub.App} err = config.SyncSystems(event) require.NoError(t, err) // Verify the original token is preserved updatedFingerprint, err := testHub.FindRecordById("fingerprints", existingFingerprint.Id) require.NoError(t, err) actualToken := updatedFingerprint.GetString("token") assert.Equal(t, migrationToken, actualToken, "Migration token should be preserved when config doesn't specify a token") // Verify fingerprint is also preserved actualFingerprint := updatedFingerprint.GetString("fingerprint") assert.Equal(t, "existing-fingerprint-from-agent", actualFingerprint, "Existing fingerprint should be preserved") // Verify system still exists and is updated correctly updatedSystem, err := testHub.FindRecordById("systems", existingSystem.Id) require.NoError(t, err) assert.Equal(t, "migrated-server", updatedSystem.GetString("name")) assert.Equal(t, "migrated.example.com", updatedSystem.GetString("host")) } ================================================ FILE: internal/hub/expirymap/expirymap.go ================================================ // Package expirymap provides a thread-safe map with expiring entries. // It supports TTL-based expiration with both lazy cleanup on access // and periodic background cleanup. package expirymap import ( "sync" "time" "github.com/pocketbase/pocketbase/tools/store" ) type val[T comparable] struct { value T expires time.Time } type ExpiryMap[T comparable] struct { store *store.Store[string, val[T]] stopChan chan struct{} stopOnce sync.Once } // New creates a new expiry map with custom cleanup interval func New[T comparable](cleanupInterval time.Duration) *ExpiryMap[T] { m := &ExpiryMap[T]{ store: store.New(map[string]val[T]{}), stopChan: make(chan struct{}), } go m.startCleaner(cleanupInterval) return m } // Set stores a value with the given TTL func (m *ExpiryMap[T]) Set(key string, value T, ttl time.Duration) { m.store.Set(key, val[T]{ value: value, expires: time.Now().Add(ttl), }) } // GetOk retrieves a value and checks if it exists and hasn't expired // Performs lazy cleanup of expired entries on access func (m *ExpiryMap[T]) GetOk(key string) (T, bool) { value, ok := m.store.GetOk(key) if !ok { return *new(T), false } // Check if expired and perform lazy cleanup if value.expires.Before(time.Now()) { m.store.Remove(key) return *new(T), false } return value.value, true } // GetByValue retrieves a value by value func (m *ExpiryMap[T]) GetByValue(val T) (key string, value T, ok bool) { for key, v := range m.store.GetAll() { if v.value == val { // check if expired if v.expires.Before(time.Now()) { m.store.Remove(key) break } return key, v.value, true } } return "", *new(T), false } // Remove explicitly removes a key func (m *ExpiryMap[T]) Remove(key string) { m.store.Remove(key) } // RemovebyValue removes a value by value func (m *ExpiryMap[T]) RemovebyValue(value T) (T, bool) { for key, val := range m.store.GetAll() { if val.value == value { m.store.Remove(key) return val.value, true } } return *new(T), false } // startCleaner runs the background cleanup process func (m *ExpiryMap[T]) startCleaner(interval time.Duration) { tick := time.Tick(interval) for { select { case <-tick: m.cleanup() case <-m.stopChan: return } } } // StopCleaner stops the background cleanup process func (m *ExpiryMap[T]) StopCleaner() { m.stopOnce.Do(func() { close(m.stopChan) }) } // cleanup removes all expired entries func (m *ExpiryMap[T]) cleanup() { now := time.Now() for key, val := range m.store.GetAll() { if val.expires.Before(now) { m.store.Remove(key) } } } // UpdateExpiration updates the expiration time of a key func (m *ExpiryMap[T]) UpdateExpiration(key string, ttl time.Duration) { value, ok := m.store.GetOk(key) if ok { value.expires = time.Now().Add(ttl) m.store.Set(key, value) } } ================================================ FILE: internal/hub/expirymap/expirymap_test.go ================================================ //go:build testing package expirymap import ( "testing" "testing/synctest" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Not using the following methods but are useful for testing // TESTING: Has checks if a key exists and hasn't expired func (m *ExpiryMap[T]) Has(key string) bool { _, ok := m.GetOk(key) return ok } // TESTING: Get retrieves a value, returns zero value if not found or expired func (m *ExpiryMap[T]) Get(key string) T { value, _ := m.GetOk(key) return value } // TESTING: Len returns the number of non-expired entries func (m *ExpiryMap[T]) Len() int { count := 0 now := time.Now() for _, val := range m.store.Values() { if val.expires.After(now) { count++ } } return count } func TestExpiryMap_BasicOperations(t *testing.T) { em := New[string](time.Hour) // Test Set and GetOk em.Set("key1", "value1", time.Hour) value, ok := em.GetOk("key1") assert.True(t, ok) assert.Equal(t, "value1", value) // Test Get value = em.Get("key1") assert.Equal(t, "value1", value) // Test Has assert.True(t, em.Has("key1")) assert.False(t, em.Has("nonexistent")) // Test Remove em.Remove("key1") assert.False(t, em.Has("key1")) } func TestExpiryMap_Expiration(t *testing.T) { em := New[string](time.Hour) // Set a value with very short TTL em.Set("shortlived", "value", time.Millisecond*10) // Should exist immediately assert.True(t, em.Has("shortlived")) // Wait for expiration time.Sleep(time.Millisecond * 20) // Should be expired and automatically cleaned up on access assert.False(t, em.Has("shortlived")) value, ok := em.GetOk("shortlived") assert.False(t, ok) assert.Equal(t, "", value) // zero value for string } func TestExpiryMap_LazyCleanup(t *testing.T) { em := New[int](time.Hour) // Set multiple values with short TTL em.Set("key1", 1, time.Millisecond*10) em.Set("key2", 2, time.Millisecond*10) em.Set("key3", 3, time.Hour) // This one won't expire // Wait for expiration time.Sleep(time.Millisecond * 20) // Access expired keys should trigger lazy cleanup _, ok := em.GetOk("key1") assert.False(t, ok) // Non-expired key should still exist value, ok := em.GetOk("key3") assert.True(t, ok) assert.Equal(t, 3, value) } func TestExpiryMap_Len(t *testing.T) { em := New[string](time.Hour) // Initially empty assert.Equal(t, 0, em.Len()) // Add some values em.Set("key1", "value1", time.Hour) em.Set("key2", "value2", time.Hour) em.Set("key3", "value3", time.Millisecond*10) // Will expire soon // Should count all initially assert.Equal(t, 3, em.Len()) // Wait for one to expire time.Sleep(time.Millisecond * 20) // Len should reflect only non-expired entries assert.Equal(t, 2, em.Len()) } func TestExpiryMap_CustomInterval(t *testing.T) { // Create with very short cleanup interval for testing em := New[string](time.Millisecond * 50) // Set a value that expires quickly em.Set("test", "value", time.Millisecond*10) // Should exist initially assert.True(t, em.Has("test")) // Wait for expiration + cleanup cycle time.Sleep(time.Millisecond * 100) // Should be cleaned up by background process // Note: This test might be flaky due to timing, but demonstrates the concept assert.False(t, em.Has("test")) } func TestExpiryMap_GenericTypes(t *testing.T) { // Test with different types t.Run("Int", func(t *testing.T) { em := New[int](time.Hour) em.Set("num", 42, time.Hour) value, ok := em.GetOk("num") assert.True(t, ok) assert.Equal(t, 42, value) }) t.Run("Struct", func(t *testing.T) { type TestStruct struct { Name string Age int } em := New[TestStruct](time.Hour) expected := TestStruct{Name: "John", Age: 30} em.Set("person", expected, time.Hour) value, ok := em.GetOk("person") assert.True(t, ok) assert.Equal(t, expected, value) }) t.Run("Pointer", func(t *testing.T) { em := New[*string](time.Hour) str := "hello" em.Set("ptr", &str, time.Hour) value, ok := em.GetOk("ptr") assert.True(t, ok) require.NotNil(t, value) assert.Equal(t, "hello", *value) }) } func TestExpiryMap_UpdateExpiration(t *testing.T) { em := New[string](time.Hour) // Set a value with short TTL em.Set("key1", "value1", time.Millisecond*50) // Verify it exists assert.True(t, em.Has("key1")) // Update expiration to a longer TTL em.UpdateExpiration("key1", time.Hour) // Wait for the original TTL to pass time.Sleep(time.Millisecond * 100) // Should still exist because expiration was updated assert.True(t, em.Has("key1")) value, ok := em.GetOk("key1") assert.True(t, ok) assert.Equal(t, "value1", value) // Try updating non-existent key (should not panic) assert.NotPanics(t, func() { em.UpdateExpiration("nonexistent", time.Hour) }) } func TestExpiryMap_ZeroValues(t *testing.T) { em := New[string](time.Hour) // Test getting non-existent key returns zero value value := em.Get("nonexistent") assert.Equal(t, "", value) // Test getting expired key returns zero value em.Set("expired", "value", time.Millisecond*10) time.Sleep(time.Millisecond * 20) value = em.Get("expired") assert.Equal(t, "", value) } func TestExpiryMap_Concurrent(t *testing.T) { em := New[int](time.Hour) // Simple concurrent access test done := make(chan bool, 2) // Writer goroutine go func() { for i := 0; i < 100; i++ { em.Set("key", i, time.Hour) time.Sleep(time.Microsecond) } done <- true }() // Reader goroutine go func() { for i := 0; i < 100; i++ { _ = em.Get("key") time.Sleep(time.Microsecond) } done <- true }() // Wait for both to complete <-done <-done // Should not panic and should have some value assert.True(t, em.Has("key")) } func TestExpiryMap_GetByValue(t *testing.T) { em := New[string](time.Hour) // Test getting by value when value exists em.Set("key1", "value1", time.Hour) em.Set("key2", "value2", time.Hour) em.Set("key3", "value1", time.Hour) // Duplicate value - should return first match // Test successful retrieval key, value, ok := em.GetByValue("value1") assert.True(t, ok) assert.Equal(t, "value1", value) assert.Contains(t, []string{"key1", "key3"}, key) // Should be one of the keys with this value // Test retrieval of unique value key, value, ok = em.GetByValue("value2") assert.True(t, ok) assert.Equal(t, "value2", value) assert.Equal(t, "key2", key) // Test getting non-existent value key, value, ok = em.GetByValue("nonexistent") assert.False(t, ok) assert.Equal(t, "", value) // zero value for string assert.Equal(t, "", key) // zero value for string } func TestExpiryMap_GetByValue_Expiration(t *testing.T) { em := New[string](time.Hour) // Set a value with short TTL em.Set("shortkey", "shortvalue", time.Millisecond*10) em.Set("longkey", "longvalue", time.Hour) // Should find the short-lived value initially key, value, ok := em.GetByValue("shortvalue") assert.True(t, ok) assert.Equal(t, "shortvalue", value) assert.Equal(t, "shortkey", key) // Wait for expiration time.Sleep(time.Millisecond * 20) // Should not find expired value and should trigger lazy cleanup key, value, ok = em.GetByValue("shortvalue") assert.False(t, ok) assert.Equal(t, "", value) assert.Equal(t, "", key) // Should still find non-expired value key, value, ok = em.GetByValue("longvalue") assert.True(t, ok) assert.Equal(t, "longvalue", value) assert.Equal(t, "longkey", key) } func TestExpiryMap_GetByValue_GenericTypes(t *testing.T) { t.Run("Int", func(t *testing.T) { em := New[int](time.Hour) em.Set("num1", 42, time.Hour) em.Set("num2", 84, time.Hour) key, value, ok := em.GetByValue(42) assert.True(t, ok) assert.Equal(t, 42, value) assert.Equal(t, "num1", key) key, value, ok = em.GetByValue(99) assert.False(t, ok) assert.Equal(t, 0, value) assert.Equal(t, "", key) }) t.Run("Struct", func(t *testing.T) { type TestStruct struct { Name string Age int } em := New[TestStruct](time.Hour) person1 := TestStruct{Name: "John", Age: 30} person2 := TestStruct{Name: "Jane", Age: 25} em.Set("person1", person1, time.Hour) em.Set("person2", person2, time.Hour) key, value, ok := em.GetByValue(person1) assert.True(t, ok) assert.Equal(t, person1, value) assert.Equal(t, "person1", key) nonexistent := TestStruct{Name: "Bob", Age: 40} key, value, ok = em.GetByValue(nonexistent) assert.False(t, ok) assert.Equal(t, TestStruct{}, value) assert.Equal(t, "", key) }) } func TestExpiryMap_RemoveValue(t *testing.T) { em := New[string](time.Hour) // Test removing existing value em.Set("key1", "value1", time.Hour) em.Set("key2", "value2", time.Hour) em.Set("key3", "value1", time.Hour) // Duplicate value // Remove by value should remove one instance removedValue, ok := em.RemovebyValue("value1") assert.True(t, ok) assert.Equal(t, "value1", removedValue) // Should still have the other instance or value2 assert.True(t, em.Has("key2")) // value2 should still exist // Check if one of the duplicate values was removed // At least one key with "value1" should be gone key1Exists := em.Has("key1") key3Exists := em.Has("key3") assert.False(t, key1Exists && key3Exists) // Both shouldn't exist assert.True(t, key1Exists || key3Exists) // At least one should be gone // Test removing non-existent value removedValue, ok = em.RemovebyValue("nonexistent") assert.False(t, ok) assert.Equal(t, "", removedValue) // zero value for string } func TestExpiryMap_RemoveValue_GenericTypes(t *testing.T) { t.Run("Int", func(t *testing.T) { em := New[int](time.Hour) em.Set("num1", 42, time.Hour) em.Set("num2", 84, time.Hour) // Remove existing value removedValue, ok := em.RemovebyValue(42) assert.True(t, ok) assert.Equal(t, 42, removedValue) assert.False(t, em.Has("num1")) assert.True(t, em.Has("num2")) // Remove non-existent value removedValue, ok = em.RemovebyValue(99) assert.False(t, ok) assert.Equal(t, 0, removedValue) }) t.Run("Struct", func(t *testing.T) { type TestStruct struct { Name string Age int } em := New[TestStruct](time.Hour) person1 := TestStruct{Name: "John", Age: 30} person2 := TestStruct{Name: "Jane", Age: 25} em.Set("person1", person1, time.Hour) em.Set("person2", person2, time.Hour) // Remove existing struct removedValue, ok := em.RemovebyValue(person1) assert.True(t, ok) assert.Equal(t, person1, removedValue) assert.False(t, em.Has("person1")) assert.True(t, em.Has("person2")) // Remove non-existent struct nonexistent := TestStruct{Name: "Bob", Age: 40} removedValue, ok = em.RemovebyValue(nonexistent) assert.False(t, ok) assert.Equal(t, TestStruct{}, removedValue) }) } func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) { em := New[string](time.Hour) // Set values with different TTLs em.Set("key1", "value1", time.Millisecond*10) // Will expire em.Set("key2", "value2", time.Hour) // Won't expire em.Set("key3", "value1", time.Hour) // Won't expire, duplicate value // Wait for first value to expire time.Sleep(time.Millisecond * 20) // Trigger lazy cleanup of the expired key _, ok := em.GetOk("key1") assert.False(t, ok) // Try to remove the remaining "value1" entry (key3) removedValue, ok := em.RemovebyValue("value1") assert.True(t, ok) assert.Equal(t, "value1", removedValue) // Should still have key2 (different value) assert.True(t, em.Has("key2")) // key1 should be gone due to expiration and key3 should be removed by value. assert.False(t, em.Has("key1")) assert.False(t, em.Has("key3")) } func TestExpiryMap_ValueOperations_Integration(t *testing.T) { em := New[string](time.Hour) // Test integration of GetByValue and RemoveValue em.Set("key1", "shared", time.Hour) em.Set("key2", "unique", time.Hour) em.Set("key3", "shared", time.Hour) // Find shared value key, value, ok := em.GetByValue("shared") assert.True(t, ok) assert.Equal(t, "shared", value) assert.Contains(t, []string{"key1", "key3"}, key) // Remove shared value removedValue, ok := em.RemovebyValue("shared") assert.True(t, ok) assert.Equal(t, "shared", removedValue) // Should still be able to find the other shared value key, value, ok = em.GetByValue("shared") assert.True(t, ok) assert.Equal(t, "shared", value) assert.Contains(t, []string{"key1", "key3"}, key) // Remove the other shared value removedValue, ok = em.RemovebyValue("shared") assert.True(t, ok) assert.Equal(t, "shared", removedValue) // Should not find shared value anymore key, value, ok = em.GetByValue("shared") assert.False(t, ok) assert.Equal(t, "", value) assert.Equal(t, "", key) // Unique value should still exist key, value, ok = em.GetByValue("unique") assert.True(t, ok) assert.Equal(t, "unique", value) assert.Equal(t, "key2", key) } func TestExpiryMap_Cleaner(t *testing.T) { synctest.Test(t, func(t *testing.T) { em := New[string](time.Second) defer em.StopCleaner() em.Set("test", "value", 500*time.Millisecond) // Wait 600ms, value is expired but cleaner hasn't run yet (interval is 1s) time.Sleep(600 * time.Millisecond) synctest.Wait() // Map should still hold the value in its internal store before lazy access or cleaner assert.Equal(t, 1, len(em.store.GetAll()), "store should still have 1 item before cleaner runs") // Wait another 500ms so cleaner (1s interval) runs time.Sleep(500 * time.Millisecond) synctest.Wait() // Wait for background goroutine to process the tick assert.Equal(t, 0, len(em.store.GetAll()), "store should be empty after cleaner runs") }) } func TestExpiryMap_StopCleaner(t *testing.T) { em := New[string](time.Hour) // Initially, stopChan is open, reading would block select { case <-em.stopChan: t.Fatal("stopChan should be open initially") default: // success } em.StopCleaner() // After StopCleaner, stopChan is closed, reading returns immediately select { case <-em.stopChan: // success default: t.Fatal("stopChan was not closed by StopCleaner") } // Calling StopCleaner again should NOT panic thanks to sync.Once assert.NotPanics(t, func() { em.StopCleaner() }) } ================================================ FILE: internal/hub/heartbeat/heartbeat.go ================================================ // Package heartbeat sends periodic outbound pings to an external monitoring // endpoint (e.g. BetterStack, Uptime Kuma, Healthchecks.io) so operators can // monitor Beszel without exposing it to the internet. package heartbeat import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "time" "github.com/henrygd/beszel" "github.com/pocketbase/pocketbase/core" ) // Default values for heartbeat configuration. const ( defaultInterval = 60 // seconds httpTimeout = 10 * time.Second ) // Payload is the JSON body sent with each heartbeat request. type Payload struct { // Status is "ok" when all non-paused systems are up, "warn" when alerts // are triggered but no systems are down, and "error" when any system is down. Status string `json:"status"` Timestamp string `json:"timestamp"` Msg string `json:"msg"` Systems SystemsSummary `json:"systems"` Down []SystemInfo `json:"down_systems,omitempty"` Alerts []AlertInfo `json:"triggered_alerts,omitempty"` Version string `json:"beszel_version"` } // SystemsSummary contains counts of systems by status. type SystemsSummary struct { Total int `json:"total"` Up int `json:"up"` Down int `json:"down"` Paused int `json:"paused"` Pending int `json:"pending"` } // SystemInfo identifies a system that is currently down. type SystemInfo struct { ID string `json:"id" db:"id"` Name string `json:"name" db:"name"` Host string `json:"host" db:"host"` } // AlertInfo describes a currently triggered alert. type AlertInfo struct { SystemID string `json:"system_id"` SystemName string `json:"system_name"` AlertName string `json:"alert_name"` Threshold float64 `json:"threshold"` } // Config holds heartbeat settings read from environment variables. type Config struct { URL string // endpoint to ping Interval int // seconds between pings Method string // HTTP method (GET or POST, default POST) } // Heartbeat manages the periodic outbound health check. type Heartbeat struct { app core.App config Config client *http.Client } // New creates a Heartbeat if configuration is present. // Returns nil if HEARTBEAT_URL is not set (feature disabled). func New(app core.App, getEnv func(string) (string, bool)) *Heartbeat { url, _ := getEnv("HEARTBEAT_URL") url = strings.TrimSpace(url) if app == nil || url == "" { return nil } interval := defaultInterval if v, ok := getEnv("HEARTBEAT_INTERVAL"); ok { if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { interval = parsed } } method := http.MethodPost if v, ok := getEnv("HEARTBEAT_METHOD"); ok { v = strings.ToUpper(strings.TrimSpace(v)) if v == http.MethodGet || v == http.MethodHead { method = v } } return &Heartbeat{ app: app, config: Config{ URL: url, Interval: interval, Method: method, }, client: &http.Client{Timeout: httpTimeout}, } } // Start begins the heartbeat loop. It blocks and should be called in a goroutine. // The loop runs until the provided stop channel is closed. func (hb *Heartbeat) Start(stop <-chan struct{}) { sanitizedURL := sanitizeHeartbeatURL(hb.config.URL) hb.app.Logger().Info("Heartbeat enabled", "url", sanitizedURL, "interval", fmt.Sprintf("%ds", hb.config.Interval), "method", hb.config.Method, ) // Send an initial heartbeat immediately on startup. hb.send() ticker := time.NewTicker(time.Duration(hb.config.Interval) * time.Second) defer ticker.Stop() for { select { case <-stop: return case <-ticker.C: hb.send() } } } // Send performs a single heartbeat ping. Exposed for the test-heartbeat API endpoint. func (hb *Heartbeat) Send() error { return hb.send() } // GetConfig returns the current heartbeat configuration. func (hb *Heartbeat) GetConfig() Config { return hb.config } func (hb *Heartbeat) send() error { var req *http.Request var err error method := normalizeMethod(hb.config.Method) if method == http.MethodGet || method == http.MethodHead { req, err = http.NewRequest(method, hb.config.URL, nil) } else { payload, payloadErr := hb.buildPayload() if payloadErr != nil { hb.app.Logger().Error("Heartbeat: failed to build payload", "err", payloadErr) return payloadErr } body, jsonErr := json.Marshal(payload) if jsonErr != nil { hb.app.Logger().Error("Heartbeat: failed to marshal payload", "err", jsonErr) return jsonErr } req, err = http.NewRequest(http.MethodPost, hb.config.URL, bytes.NewReader(body)) if err == nil { req.Header.Set("Content-Type", "application/json") } } if err != nil { hb.app.Logger().Error("Heartbeat: failed to create request", "err", err) return err } req.Header.Set("User-Agent", "Beszel-Heartbeat") resp, err := hb.client.Do(req) if err != nil { hb.app.Logger().Error("Heartbeat: request failed", "url", sanitizeHeartbeatURL(hb.config.URL), "err", err) return err } defer resp.Body.Close() if resp.StatusCode >= 400 { hb.app.Logger().Warn("Heartbeat: non-success response", "url", sanitizeHeartbeatURL(hb.config.URL), "status", resp.StatusCode, ) return fmt.Errorf("heartbeat endpoint returned status %d", resp.StatusCode) } return nil } func (hb *Heartbeat) buildPayload() (*Payload, error) { db := hb.app.DB() // Count systems by status. var systemCounts []struct { Status string `db:"status"` Count int `db:"cnt"` } err := db.NewQuery("SELECT status, COUNT(*) as cnt FROM systems GROUP BY status").All(&systemCounts) if err != nil { return nil, fmt.Errorf("query system counts: %w", err) } summary := SystemsSummary{} for _, sc := range systemCounts { switch sc.Status { case "up": summary.Up = sc.Count case "down": summary.Down = sc.Count case "paused": summary.Paused = sc.Count case "pending": summary.Pending = sc.Count } summary.Total += sc.Count } // Get names of down systems. var downSystems []SystemInfo if summary.Down > 0 { err = db.NewQuery("SELECT id, name, host FROM systems WHERE status = 'down'").All(&downSystems) if err != nil { return nil, fmt.Errorf("query down systems: %w", err) } } // Get triggered alerts with system names. var triggeredAlerts []struct { SystemID string `db:"system"` SystemName string `db:"system_name"` AlertName string `db:"name"` Value float64 `db:"value"` } err = db.NewQuery(` SELECT a.system, s.name as system_name, a.name, a.value FROM alerts a JOIN systems s ON a.system = s.id WHERE a.triggered = true `).All(&triggeredAlerts) if err != nil { // Non-fatal: alerts info is supplementary. triggeredAlerts = nil } alerts := make([]AlertInfo, 0, len(triggeredAlerts)) for _, ta := range triggeredAlerts { alerts = append(alerts, AlertInfo{ SystemID: ta.SystemID, SystemName: ta.SystemName, AlertName: ta.AlertName, Threshold: ta.Value, }) } // Determine overall status. status := "ok" msg := "All systems operational" if summary.Down > 0 { status = "error" names := make([]string, len(downSystems)) for i, ds := range downSystems { names[i] = ds.Name } msg = fmt.Sprintf("%d system(s) down: %s", summary.Down, strings.Join(names, ", ")) } else if len(alerts) > 0 { status = "warn" msg = fmt.Sprintf("%d alert(s) triggered", len(alerts)) } return &Payload{ Status: status, Timestamp: time.Now().UTC().Format(time.RFC3339), Msg: msg, Systems: summary, Down: downSystems, Alerts: alerts, Version: beszel.Version, }, nil } func normalizeMethod(method string) string { upper := strings.ToUpper(strings.TrimSpace(method)) if upper == http.MethodGet || upper == http.MethodHead || upper == http.MethodPost { return upper } return http.MethodPost } func sanitizeHeartbeatURL(rawURL string) string { parsed, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "" } return parsed.Scheme + "://" + parsed.Host } ================================================ FILE: internal/hub/heartbeat/heartbeat_test.go ================================================ //go:build testing package heartbeat_test import ( "encoding/json" "io" "net/http" "net/http/httptest" "testing" "github.com/henrygd/beszel/internal/hub/heartbeat" beszeltests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNew(t *testing.T) { t.Run("returns nil when app is missing", func(t *testing.T) { hb := heartbeat.New(nil, envGetter(map[string]string{ "HEARTBEAT_URL": "https://heartbeat.example.com/ping", })) assert.Nil(t, hb) }) t.Run("returns nil when URL is missing", func(t *testing.T) { app := newTestHub(t) hb := heartbeat.New(app.App, func(string) (string, bool) { return "", false }) assert.Nil(t, hb) }) t.Run("parses and normalizes config values", func(t *testing.T) { app := newTestHub(t) env := map[string]string{ "HEARTBEAT_URL": " https://heartbeat.example.com/ping ", "HEARTBEAT_INTERVAL": "90", "HEARTBEAT_METHOD": "head", } getEnv := func(key string) (string, bool) { v, ok := env[key] return v, ok } hb := heartbeat.New(app.App, getEnv) require.NotNil(t, hb) cfg := hb.GetConfig() assert.Equal(t, "https://heartbeat.example.com/ping", cfg.URL) assert.Equal(t, 90, cfg.Interval) assert.Equal(t, http.MethodHead, cfg.Method) }) } func TestSendGETDoesNotRequireAppOrDB(t *testing.T) { app := newTestHub(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodGet, r.Method) assert.Equal(t, "Beszel-Heartbeat", r.Header.Get("User-Agent")) w.WriteHeader(http.StatusOK) })) defer server.Close() hb := heartbeat.New(app.App, envGetter(map[string]string{ "HEARTBEAT_URL": server.URL, "HEARTBEAT_METHOD": "GET", })) require.NotNil(t, hb) require.NoError(t, hb.Send()) } func TestSendReturnsErrorOnHTTPFailureStatus(t *testing.T) { app := newTestHub(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer server.Close() hb := heartbeat.New(app.App, envGetter(map[string]string{ "HEARTBEAT_URL": server.URL, "HEARTBEAT_METHOD": "GET", })) require.NotNil(t, hb) err := hb.Send() require.Error(t, err) assert.ErrorContains(t, err, "heartbeat endpoint returned status 500") } func TestSendPOSTBuildsExpectedStatuses(t *testing.T) { tests := []struct { name string setup func(t *testing.T, app *beszeltests.TestHub, user *core.Record) expectStatus string expectMsgPart string expectDown int expectAlerts int expectTotal int expectUp int expectPaused int expectPending int expectDownSumm int }{ { name: "error when at least one system is down", setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) { downSystem := createTestSystem(t, app, user.Id, "db-1", "10.0.0.1", "down") _ = createTestSystem(t, app, user.Id, "web-1", "10.0.0.2", "up") createTriggeredAlert(t, app, user.Id, downSystem.Id, "CPU", 95) }, expectStatus: "error", expectMsgPart: "1 system(s) down", expectDown: 1, expectAlerts: 1, expectTotal: 2, expectUp: 1, expectDownSumm: 1, }, { name: "warn when only alerts are triggered", setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) { system := createTestSystem(t, app, user.Id, "api-1", "10.1.0.1", "up") createTriggeredAlert(t, app, user.Id, system.Id, "CPU", 90) }, expectStatus: "warn", expectMsgPart: "1 alert(s) triggered", expectDown: 0, expectAlerts: 1, expectTotal: 1, expectUp: 1, expectDownSumm: 0, }, { name: "ok when no down systems and no alerts", setup: func(t *testing.T, app *beszeltests.TestHub, user *core.Record) { _ = createTestSystem(t, app, user.Id, "node-1", "10.2.0.1", "up") _ = createTestSystem(t, app, user.Id, "node-2", "10.2.0.2", "paused") _ = createTestSystem(t, app, user.Id, "node-3", "10.2.0.3", "pending") }, expectStatus: "ok", expectMsgPart: "All systems operational", expectDown: 0, expectAlerts: 0, expectTotal: 3, expectUp: 1, expectPaused: 1, expectPending: 1, expectDownSumm: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { app := newTestHub(t) user := createTestUser(t, app) tt.setup(t, app, user) type requestCapture struct { method string userAgent string contentType string payload heartbeat.Payload } captured := make(chan requestCapture, 1) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() body, err := io.ReadAll(r.Body) require.NoError(t, err) var payload heartbeat.Payload require.NoError(t, json.Unmarshal(body, &payload)) captured <- requestCapture{ method: r.Method, userAgent: r.Header.Get("User-Agent"), contentType: r.Header.Get("Content-Type"), payload: payload, } w.WriteHeader(http.StatusNoContent) })) defer server.Close() hb := heartbeat.New(app.App, envGetter(map[string]string{ "HEARTBEAT_URL": server.URL, "HEARTBEAT_METHOD": "POST", })) require.NotNil(t, hb) require.NoError(t, hb.Send()) req := <-captured assert.Equal(t, http.MethodPost, req.method) assert.Equal(t, "Beszel-Heartbeat", req.userAgent) assert.Equal(t, "application/json", req.contentType) assert.Equal(t, tt.expectStatus, req.payload.Status) assert.Contains(t, req.payload.Msg, tt.expectMsgPart) assert.Equal(t, tt.expectDown, len(req.payload.Down)) assert.Equal(t, tt.expectAlerts, len(req.payload.Alerts)) assert.Equal(t, tt.expectTotal, req.payload.Systems.Total) assert.Equal(t, tt.expectUp, req.payload.Systems.Up) assert.Equal(t, tt.expectDownSumm, req.payload.Systems.Down) assert.Equal(t, tt.expectPaused, req.payload.Systems.Paused) assert.Equal(t, tt.expectPending, req.payload.Systems.Pending) }) } } func newTestHub(t *testing.T) *beszeltests.TestHub { t.Helper() app, err := beszeltests.NewTestHub(t.TempDir()) require.NoError(t, err) t.Cleanup(app.Cleanup) return app } func createTestUser(t *testing.T, app *beszeltests.TestHub) *core.Record { t.Helper() user, err := beszeltests.CreateUser(app.App, "admin@example.com", "password123") require.NoError(t, err) return user } func createTestSystem(t *testing.T, app *beszeltests.TestHub, userID, name, host, status string) *core.Record { t.Helper() system, err := beszeltests.CreateRecord(app.App, "systems", map[string]any{ "name": name, "host": host, "port": "45876", "users": []string{userID}, "status": status, }) require.NoError(t, err) return system } func createTriggeredAlert(t *testing.T, app *beszeltests.TestHub, userID, systemID, name string, threshold float64) *core.Record { t.Helper() alert, err := beszeltests.CreateRecord(app.App, "alerts", map[string]any{ "name": name, "system": systemID, "user": userID, "value": threshold, "min": 0, "triggered": true, }) require.NoError(t, err) return alert } func envGetter(values map[string]string) func(string) (string, bool) { return func(key string) (string, bool) { v, ok := values[key] return v, ok } } ================================================ FILE: internal/hub/hub.go ================================================ // Package hub handles updating systems and serving the web UI. package hub import ( "crypto/ed25519" "encoding/pem" "fmt" "net/http" "net/url" "os" "path" "regexp" "strings" "time" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/alerts" "github.com/henrygd/beszel/internal/hub/config" "github.com/henrygd/beszel/internal/hub/heartbeat" "github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/users" "github.com/google/uuid" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) type Hub struct { core.App *alerts.AlertManager um *users.UserManager rm *records.RecordManager sm *systems.SystemManager hb *heartbeat.Heartbeat hbStop chan struct{} pubKey string signer ssh.Signer appURL string } var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`) // NewHub creates a new Hub instance with default configuration func NewHub(app core.App) *Hub { hub := &Hub{} hub.App = app hub.AlertManager = alerts.NewAlertManager(hub) hub.um = users.NewUserManager(hub) hub.rm = records.NewRecordManager(hub) hub.sm = systems.NewSystemManager(hub) hub.appURL, _ = GetEnv("APP_URL") hub.hb = heartbeat.New(app, GetEnv) if hub.hb != nil { hub.hbStop = make(chan struct{}) } return hub } // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. func GetEnv(key string) (value string, exists bool) { if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists { return value, exists } // Fallback to the old unprefixed key return os.LookupEnv(key) } func (h *Hub) StartHub() error { h.App.OnServe().BindFunc(func(e *core.ServeEvent) error { // initialize settings / collections if err := h.initialize(e); err != nil { return err } // sync systems with config if err := config.SyncSystems(e); err != nil { return err } // register middlewares h.registerMiddlewares(e) // register api routes if err := h.registerApiRoutes(e); err != nil { return err } // register cron jobs if err := h.registerCronJobs(e); err != nil { return err } // start server if err := h.startServer(e); err != nil { return err } // start system updates if err := h.sm.Initialize(); err != nil { return err } // start heartbeat if configured if h.hb != nil { go h.hb.Start(h.hbStop) } return e.Next() }) // TODO: move to users package // handle default values for user / user_settings creation h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) if pb, ok := h.App.(*pocketbase.PocketBase); ok { // log.Println("Starting pocketbase") err := pb.Start() if err != nil { return err } } return nil } // initialize sets up initial configuration (collections, settings, etc.) func (h *Hub) initialize(e *core.ServeEvent) error { // set general settings settings := e.App.Settings() // batch requests (for global alerts) settings.Batch.Enabled = true // set URL if BASE_URL env is set if h.appURL != "" { settings.Meta.AppURL = h.appURL } if err := e.App.Save(settings); err != nil { return err } // set auth settings if err := setCollectionAuthSettings(e.App); err != nil { return err } return nil } // setCollectionAuthSettings sets up default authentication settings for the app func setCollectionAuthSettings(app core.App) error { usersCollection, err := app.FindCollectionByNameOrId("users") if err != nil { return err } superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) if err != nil { return err } // disable email auth if DISABLE_PASSWORD_AUTH env var is set disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH") usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true" usersCollection.PasswordAuth.IdentityFields = []string{"email"} // allow oauth user creation if USER_CREATION is set if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" { cr := "@request.context = 'oauth2'" usersCollection.CreateRule = &cr } else { usersCollection.CreateRule = nil } // enable mfaOtp mfa if MFA_OTP env var is set mfaOtp, _ := GetEnv("MFA_OTP") usersCollection.OTP.Length = 6 superusersCollection.OTP.Length = 6 usersCollection.OTP.Enabled = mfaOtp == "true" usersCollection.MFA.Enabled = mfaOtp == "true" superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers" superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers" if err := app.Save(superusersCollection); err != nil { return err } if err := app.Save(usersCollection); err != nil { return err } shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS") // allow all users to access systems if SHARE_ALL_SYSTEMS is set systemsCollection, err := app.FindCollectionByNameOrId("systems") if err != nil { return err } var systemsReadRule string if shareAllSystems == "true" { systemsReadRule = "@request.auth.id != \"\"" } else { systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id" } updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\"" systemsCollection.ListRule = &systemsReadRule systemsCollection.ViewRule = &systemsReadRule systemsCollection.UpdateRule = &updateDeleteRule systemsCollection.DeleteRule = &updateDeleteRule if err := app.Save(systemsCollection); err != nil { return err } // allow all users to access all containers if SHARE_ALL_SYSTEMS is set containersCollection, err := app.FindCollectionByNameOrId("containers") if err != nil { return err } containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1) containersCollection.ListRule = &containersListRule if err := app.Save(containersCollection); err != nil { return err } // allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set // these collections all have a "system" relation field systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"} for _, collectionName := range systemRelatedCollections { collection, err := app.FindCollectionByNameOrId(collectionName) if err != nil { return err } collection.ListRule = &containersListRule // set viewRule for collections that need it (system_details, smart_devices) if collection.ViewRule != nil { collection.ViewRule = &containersListRule } // set deleteRule for smart_devices (allows user to dismiss disk warnings) if collectionName == "smart_devices" { deleteRule := containersListRule + " && @request.auth.role != \"readonly\"" collection.DeleteRule = &deleteRule } if err := app.Save(collection); err != nil { return err } } return nil } // registerCronJobs sets up scheduled tasks func (h *Hub) registerCronJobs(_ *core.ServeEvent) error { // delete old system_stats and alerts_history records once every hour h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords) // create longer records every 10 minutes h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords) return nil } // custom middlewares func (h *Hub) registerMiddlewares(se *core.ServeEvent) { // authorizes request with user matching the provided email authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) { if e.Auth != nil || email == "" { return e.Next() } isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost e.Auth, err = e.App.FindFirstRecordByData("users", "email", email) if err != nil || !isAuthRefresh { return e.Next() } // auth refresh endpoint, make sure token is set in header token, _ := e.Auth.NewAuthToken() e.Request.Header.Set("Authorization", token) return e.Next() } // authenticate with trusted header if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" { se.Router.BindFunc(func(e *core.RequestEvent) error { return authorizeRequestWithEmail(e, autoLogin) }) } // authenticate with trusted header if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" { se.Router.BindFunc(func(e *core.RequestEvent) error { return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader)) }) } } // custom api routes func (h *Hub) registerApiRoutes(se *core.ServeEvent) error { // auth protected routes apiAuth := se.Router.Group("/api/beszel") apiAuth.Bind(apis.RequireAuth()) // auth optional routes apiNoAuth := se.Router.Group("/api/beszel") // create first user endpoint only needed if no users exist if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 { apiNoAuth.POST("/create-user", h.um.CreateFirstUser) } // check if first time setup on login page apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error { total, err := e.App.CountRecords("users") return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0}) }) // get public key and version apiAuth.GET("/getkey", func(e *core.RequestEvent) error { return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version}) }) // send test notification apiAuth.POST("/test-notification", h.SendTestNotification) // heartbeat status and test apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus) apiAuth.POST("/test-heartbeat", h.testHeartbeat) // get config.yml content apiAuth.GET("/config-yaml", config.GetYamlConfig) // handle agent websocket connection apiNoAuth.GET("/agent-connect", h.handleAgentConnect) // get or create universal tokens apiAuth.GET("/universal-token", h.getUniversalToken) // update / delete user alerts apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts) apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts) // refresh SMART devices for a system apiAuth.POST("/smart/refresh", h.refreshSmartData) // get systemd service details apiAuth.GET("/systemd/info", h.getSystemdInfo) // /containers routes if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" { // get container logs apiAuth.GET("/containers/logs", h.getContainerLogs) // get container info apiAuth.GET("/containers/info", h.getContainerInfo) } return nil } // Handler for universal token API endpoint (create, read, delete) func (h *Hub) getUniversalToken(e *core.RequestEvent) error { tokenMap := universalTokenMap.GetMap() userID := e.Auth.Id query := e.Request.URL.Query() token := query.Get("token") enable := query.Get("enable") permanent := query.Get("permanent") // helper for deleting any existing permanent token record for this user deletePermanent := func() error { rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) if err != nil { return nil // no record } return h.Delete(rec) } // helper for upserting a permanent token record for this user upsertPermanent := func(token string) error { rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}) if err == nil { rec.Set("token", token) return h.Save(rec) } col, err := h.FindCachedCollectionByNameOrId("universal_tokens") if err != nil { return err } newRec := core.NewRecord(col) newRec.Set("user", userID) newRec.Set("token", token) return h.Save(newRec) } // Disable universal tokens (both ephemeral and permanent) if enable == "0" { tokenMap.RemovebyValue(userID) _ = deletePermanent() return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) } // Enable universal token (ephemeral or permanent) if enable == "1" { if token == "" { token = uuid.New().String() } if permanent == "1" { // make token permanent (persist across restarts) tokenMap.RemovebyValue(userID) if err := upsertPermanent(token); err != nil { return err } return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true}) } // default: ephemeral mode (1 hour) _ = deletePermanent() tokenMap.Set(token, userID, time.Hour) return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) } // Read current state // Prefer permanent token if it exists. if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil { dbToken := rec.GetString("token") // If no token was provided, or the caller is asking about their permanent token, return it. if token == "" || token == dbToken { return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true}) } // Token doesn't match their permanent token (avoid leaking other info) return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false}) } // No permanent token; fall back to ephemeral token map. if token == "" { // return existing token if it exists if token, _, ok := tokenMap.GetByValue(userID); ok { return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false}) } // if no token is provided, generate a new one token = uuid.New().String() } // Token is considered active only if it belongs to the current user. activeUser, ok := tokenMap.GetOk(token) active := ok && activeUser == userID response := map[string]any{"token": token, "active": active, "permanent": false} return e.JSON(http.StatusOK, response) } // getHeartbeatStatus returns current heartbeat configuration and whether it's enabled func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error { if e.Auth.GetString("role") != "admin" { return e.ForbiddenError("Requires admin role", nil) } if h.hb == nil { return e.JSON(http.StatusOK, map[string]any{ "enabled": false, "msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring", }) } cfg := h.hb.GetConfig() return e.JSON(http.StatusOK, map[string]any{ "enabled": true, "url": cfg.URL, "interval": cfg.Interval, "method": cfg.Method, }) } // testHeartbeat triggers a single heartbeat ping and returns the result func (h *Hub) testHeartbeat(e *core.RequestEvent) error { if e.Auth.GetString("role") != "admin" { return e.ForbiddenError("Requires admin role", nil) } if h.hb == nil { return e.JSON(http.StatusOK, map[string]any{ "err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.", }) } if err := h.hb.Send(); err != nil { return e.JSON(http.StatusOK, map[string]any{"err": err.Error()}) } return e.JSON(http.StatusOK, map[string]any{"err": false}) } // containerRequestHandler handles both container logs and info requests func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error { systemID := e.Request.URL.Query().Get("system") containerID := e.Request.URL.Query().Get("container") if systemID == "" || containerID == "" { return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) } if !containerIDPattern.MatchString(containerID) { return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"}) } system, err := h.sm.GetSystem(systemID) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) } data, err := fetchFunc(system, containerID) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) } return e.JSON(http.StatusOK, map[string]string{responseKey: data}) } // getContainerLogs handles GET /api/beszel/containers/logs requests func (h *Hub) getContainerLogs(e *core.RequestEvent) error { return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { return system.FetchContainerLogsFromAgent(containerID) }, "logs") } func (h *Hub) getContainerInfo(e *core.RequestEvent) error { return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) { return system.FetchContainerInfoFromAgent(containerID) }, "info") } // getSystemdInfo handles GET /api/beszel/systemd/info requests func (h *Hub) getSystemdInfo(e *core.RequestEvent) error { query := e.Request.URL.Query() systemID := query.Get("system") serviceName := query.Get("service") if systemID == "" || serviceName == "" { return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"}) } system, err := h.sm.GetSystem(systemID) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) } details, err := system.FetchSystemdInfoFromAgent(serviceName) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) } e.Response.Header().Set("Cache-Control", "public, max-age=60") return e.JSON(http.StatusOK, map[string]any{"details": details}) } // refreshSmartData handles POST /api/beszel/smart/refresh requests // Fetches fresh SMART data from the agent and updates the collection func (h *Hub) refreshSmartData(e *core.RequestEvent) error { systemID := e.Request.URL.Query().Get("system") if systemID == "" { return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"}) } system, err := h.sm.GetSystem(systemID) if err != nil { return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"}) } // Fetch and save SMART devices if err := system.FetchAndSaveSmartDevices(); err != nil { return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) } // generates key pair if it doesn't exist and returns signer func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) { if h.signer != nil { return h.signer, nil } if dataDir == "" { dataDir = h.DataDir() } privateKeyPath := path.Join(dataDir, "id_ed25519") // check if the key pair already exists existingKey, err := os.ReadFile(privateKeyPath) if err == nil { private, err := ssh.ParsePrivateKey(existingKey) if err != nil { return nil, fmt.Errorf("failed to parse private key: %s", err) } 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 _, privKey, err := ed25519.GenerateKey(nil) if err != nil { return nil, err } privKeyPem, err := ssh.MarshalPrivateKey(privKey, "") if err != nil { return nil, err } 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 sshPrivate, _ := ssh.NewSignerFromSigner(privKey) pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey()) h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n") h.Logger().Info("ed25519 key pair generated successfully.") h.Logger().Info("Saved to: " + privateKeyPath) 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 } ================================================ FILE: internal/hub/hub_test.go ================================================ //go:build testing package hub_test import ( "bytes" "crypto/ed25519" "encoding/json" "encoding/pem" "io" "net/http" "os" "path/filepath" "strings" "testing" "github.com/henrygd/beszel/internal/migrations" beszelTests "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/pocketbase/core" pbTests "github.com/pocketbase/pocketbase/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) // marshal to json and return an io.Reader (for use in ApiScenario.Body) func jsonReader(v any) io.Reader { data, err := json.Marshal(v) if err != nil { panic(err) } return bytes.NewReader(data) } func TestMakeLink(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) 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, _ := beszelTests.NewTestHub(t.TempDir()) // 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.SetPubkey("") 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.GetPubkey(), "h.pubKey should be set after key generation") assert.True(t, strings.HasPrefix(hub.GetPubkey(), "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.SetPubkey("") 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.GetPubkey(), "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.SetPubkey("") // 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.GetPubkey(), "h.pubKey should not be set if there was an error") }) } }) } func TestApiRoutesAuthentication(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() hub.StartHub() // Create test user and get auth token user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123") require.NoError(t, err, "Failed to create test user") adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{ "email": "admin@example.com", "password": "password123", "role": "admin", }) require.NoError(t, err, "Failed to create admin user") adminUserToken, err := adminUser.NewAuthToken() // superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{ // "email": "superuser@example.com", // "password": "password123", // }) // require.NoError(t, err, "Failed to create superuser") userToken, err := user.NewAuthToken() require.NoError(t, err, "Failed to create auth token") // Create test system for user-alerts endpoints system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "users": []string{user.Id}, "host": "127.0.0.1", }) require.NoError(t, err, "Failed to create test system") testAppFactory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenarios := []beszelTests.ApiScenario{ // Auth Protected Routes - Should require authentication { Name: "POST /test-notification - no auth should fail", Method: http.MethodPost, URL: "/api/beszel/test-notification", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "url": "generic://127.0.0.1", }), }, { Name: "POST /test-notification - with auth should succeed", Method: http.MethodPost, URL: "/api/beszel/test-notification", TestAppFactory: testAppFactory, Headers: map[string]string{ "Authorization": userToken, }, Body: jsonReader(map[string]any{ "url": "generic://127.0.0.1", }), ExpectedStatus: 200, ExpectedContent: []string{"sending message"}, }, { Name: "GET /config-yaml - no auth should fail", Method: http.MethodGet, URL: "/api/beszel/config-yaml", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /config-yaml - with user auth should fail", Method: http.MethodGet, URL: "/api/beszel/config-yaml", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 403, ExpectedContent: []string{"Requires admin"}, TestAppFactory: testAppFactory, }, { Name: "GET /config-yaml - with admin auth should succeed", Method: http.MethodGet, URL: "/api/beszel/config-yaml", Headers: map[string]string{ "Authorization": adminUserToken, }, ExpectedStatus: 200, ExpectedContent: []string{"test-system"}, TestAppFactory: testAppFactory, }, { Name: "GET /heartbeat-status - no auth should fail", Method: http.MethodGet, URL: "/api/beszel/heartbeat-status", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /heartbeat-status - with user auth should fail", Method: http.MethodGet, URL: "/api/beszel/heartbeat-status", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 403, ExpectedContent: []string{"Requires admin role"}, TestAppFactory: testAppFactory, }, { Name: "GET /heartbeat-status - with admin auth should succeed", Method: http.MethodGet, URL: "/api/beszel/heartbeat-status", Headers: map[string]string{ "Authorization": adminUserToken, }, ExpectedStatus: 200, ExpectedContent: []string{`"enabled":false`}, TestAppFactory: testAppFactory, }, { Name: "POST /test-heartbeat - with user auth should fail", Method: http.MethodPost, URL: "/api/beszel/test-heartbeat", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 403, ExpectedContent: []string{"Requires admin role"}, TestAppFactory: testAppFactory, }, { Name: "POST /test-heartbeat - with admin auth should report disabled state", Method: http.MethodPost, URL: "/api/beszel/test-heartbeat", Headers: map[string]string{ "Authorization": adminUserToken, }, ExpectedStatus: 200, ExpectedContent: []string{"Heartbeat not configured"}, TestAppFactory: testAppFactory, }, { Name: "GET /universal-token - no auth should fail", Method: http.MethodGet, URL: "/api/beszel/universal-token", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /universal-token - with auth should succeed", Method: http.MethodGet, URL: "/api/beszel/universal-token", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"active", "token", "permanent"}, TestAppFactory: testAppFactory, }, { Name: "GET /universal-token - enable permanent should succeed", Method: http.MethodGet, URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"}, TestAppFactory: testAppFactory, }, { Name: "POST /user-alerts - no auth should fail", Method: http.MethodPost, URL: "/api/beszel/user-alerts", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "name": "CPU", "value": 80, "min": 10, "systems": []string{system.Id}, }), }, { Name: "POST /user-alerts - with auth should succeed", Method: http.MethodPost, URL: "/api/beszel/user-alerts", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"\"success\":true"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "name": "CPU", "value": 80, "min": 10, "systems": []string{system.Id}, }), }, { Name: "DELETE /user-alerts - no auth should fail", Method: http.MethodDelete, URL: "/api/beszel/user-alerts", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "name": "CPU", "systems": []string{system.Id}, }), }, { Name: "DELETE /user-alerts - with auth should succeed", Method: http.MethodDelete, URL: "/api/beszel/user-alerts", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"\"success\":true"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "name": "CPU", "systems": []string{system.Id}, }), BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { // Create an alert to delete beszelTests.CreateRecord(app, "alerts", map[string]any{ "name": "CPU", "system": system.Id, "user": user.Id, "value": 80, "min": 10, }) }, }, { Name: "GET /containers/logs - no auth should fail", Method: http.MethodGet, URL: "/api/beszel/containers/logs?system=test-system&container=test-container", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/logs - with auth but missing system param should fail", Method: http.MethodGet, URL: "/api/beszel/containers/logs?container=test-container", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 400, ExpectedContent: []string{"system and container parameters are required"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/logs - with auth but missing container param should fail", Method: http.MethodGet, URL: "/api/beszel/containers/logs?system=test-system", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 400, ExpectedContent: []string{"system and container parameters are required"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/logs - with auth but invalid system should fail", Method: http.MethodGet, URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 404, ExpectedContent: []string{"system not found"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/logs - traversal container should fail validation", Method: http.MethodGet, URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 400, ExpectedContent: []string{"invalid container parameter"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/info - traversal container should fail validation", Method: http.MethodGet, URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 400, ExpectedContent: []string{"invalid container parameter"}, TestAppFactory: testAppFactory, }, { Name: "GET /containers/info - non-hex container should fail validation", Method: http.MethodGet, URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 400, ExpectedContent: []string{"invalid container parameter"}, TestAppFactory: testAppFactory, }, // Auth Optional Routes - Should work without authentication { Name: "GET /getkey - no auth should fail", Method: http.MethodGet, URL: "/api/beszel/getkey", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /getkey - with auth should also succeed", Method: http.MethodGet, URL: "/api/beszel/getkey", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"\"key\":", "\"v\":"}, TestAppFactory: testAppFactory, }, { Name: "GET /first-run - no auth should succeed", Method: http.MethodGet, URL: "/api/beszel/first-run", ExpectedStatus: 200, ExpectedContent: []string{"\"firstRun\":false"}, TestAppFactory: testAppFactory, }, { Name: "GET /first-run - with auth should also succeed", Method: http.MethodGet, URL: "/api/beszel/first-run", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 200, ExpectedContent: []string{"\"firstRun\":false"}, TestAppFactory: testAppFactory, }, { Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)", Method: http.MethodGet, URL: "/api/beszel/agent-connect", ExpectedStatus: 400, ExpectedContent: []string{}, TestAppFactory: testAppFactory, }, { Name: "POST /test-notification - invalid auth token should fail", Method: http.MethodPost, URL: "/api/beszel/test-notification", Body: jsonReader(map[string]any{ "url": "generic://127.0.0.1", }), Headers: map[string]string{ "Authorization": "invalid-token", }, ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "POST /user-alerts - invalid auth token should fail", Method: http.MethodPost, URL: "/api/beszel/user-alerts", Headers: map[string]string{ "Authorization": "invalid-token", }, ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, Body: jsonReader(map[string]any{ "name": "CPU", "value": 80, "min": 10, "systems": []string{system.Id}, }), }, } for _, scenario := range scenarios { scenario.Test(t) } } func TestFirstUserCreation(t *testing.T) { t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() hub.StartHub() testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenarios := []beszelTests.ApiScenario{ { Name: "POST /create-user - should be available when no users exist", Method: http.MethodPost, URL: "/api/beszel/create-user", Body: jsonReader(map[string]any{ "email": "firstuser@example.com", "password": "password123", }), ExpectedStatus: 200, ExpectedContent: []string{"User created"}, TestAppFactory: testAppFactoryExisting, BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { userCount, err := hub.CountRecords("users") require.NoError(t, err) require.Zero(t, userCount, "Should start with no users") superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) require.NoError(t, err) require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser") require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser") }, AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { userCount, err := hub.CountRecords("users") require.NoError(t, err) require.EqualValues(t, 1, userCount, "Should have created one user") superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) require.NoError(t, err) require.EqualValues(t, 1, len(superusers), "Should have created one superuser") require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser") }, }, { Name: "POST /create-user - should not be available when users exist", Method: http.MethodPost, URL: "/api/beszel/create-user", Body: jsonReader(map[string]any{ "email": "firstuser@example.com", "password": "password123", }), ExpectedStatus: 404, ExpectedContent: []string{"wasn't found"}, TestAppFactory: testAppFactoryExisting, }, } for _, scenario := range scenarios { scenario.Test(t) } }) t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) { os.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com") os.Setenv("BESZEL_HUB_USER_PASSWORD", "password123") defer os.Unsetenv("BESZEL_HUB_USER_EMAIL") defer os.Unsetenv("BESZEL_HUB_USER_PASSWORD") hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() hub.StartHub() testAppFactory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenario := beszelTests.ApiScenario{ Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set", Method: http.MethodPost, URL: "/api/beszel/create-user", ExpectedStatus: 404, ExpectedContent: []string{"wasn't found"}, TestAppFactory: testAppFactory, BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { users, err := hub.FindAllRecords("users") require.NoError(t, err) require.EqualValues(t, 1, len(users), "Should start with one user") require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) require.NoError(t, err) require.EqualValues(t, 1, len(superusers), "Should start with one superuser") require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") }, AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) { users, err := hub.FindAllRecords("users") require.NoError(t, err) require.EqualValues(t, 1, len(users), "Should still have one user") require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user") superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers) require.NoError(t, err) require.EqualValues(t, 1, len(superusers), "Should still have one superuser") require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser") }, } scenario.Test(t) }) } func TestCreateUserEndpointAvailability(t *testing.T) { t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() // Ensure no users exist userCount, err := hub.CountRecords("users") require.NoError(t, err) require.Zero(t, userCount, "Should start with no users") hub.StartHub() testAppFactory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenario := beszelTests.ApiScenario{ Name: "POST /create-user - should be available when no users exist", Method: http.MethodPost, URL: "/api/beszel/create-user", Body: jsonReader(map[string]any{ "email": "firstuser@example.com", "password": "password123", }), ExpectedStatus: 200, ExpectedContent: []string{"User created"}, TestAppFactory: testAppFactory, } scenario.Test(t) // Verify user was created userCount, err = hub.CountRecords("users") require.NoError(t, err) require.EqualValues(t, 1, userCount, "Should have created one user") }) t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) { hub, _ := beszelTests.NewTestHub(t.TempDir()) defer hub.Cleanup() // Create a user first _, err := beszelTests.CreateUser(hub, "existing@example.com", "password") require.NoError(t, err) hub.StartHub() testAppFactory := func(t testing.TB) *pbTests.TestApp { return hub.TestApp } scenario := beszelTests.ApiScenario{ Name: "POST /create-user - should not be available when users exist", Method: http.MethodPost, URL: "/api/beszel/create-user", Body: jsonReader(map[string]any{ "email": "another@example.com", "password": "password123", }), ExpectedStatus: 404, ExpectedContent: []string{"wasn't found"}, TestAppFactory: testAppFactory, } scenario.Test(t) }) } func TestAutoLoginMiddleware(t *testing.T) { var hubs []*beszelTests.TestHub defer func() { defer os.Unsetenv("AUTO_LOGIN") for _, hub := range hubs { hub.Cleanup() } }() os.Setenv("AUTO_LOGIN", "user@test.com") testAppFactory := func(t testing.TB) *pbTests.TestApp { hub, _ := beszelTests.NewTestHub(t.TempDir()) hubs = append(hubs, hub) hub.StartHub() return hub.TestApp } scenarios := []beszelTests.ApiScenario{ { Name: "GET /getkey - without auto login should fail", Method: http.MethodGet, URL: "/api/beszel/getkey", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /getkey - with auto login should fail if no matching user", Method: http.MethodGet, URL: "/api/beszel/getkey", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /getkey - with auto login should succeed", Method: http.MethodGet, URL: "/api/beszel/getkey", ExpectedStatus: 200, ExpectedContent: []string{"\"key\":", "\"v\":"}, TestAppFactory: testAppFactory, BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { beszelTests.CreateUser(app, "user@test.com", "password123") }, }, } for _, scenario := range scenarios { scenario.Test(t) } } func TestTrustedHeaderMiddleware(t *testing.T) { var hubs []*beszelTests.TestHub defer func() { defer os.Unsetenv("TRUSTED_AUTH_HEADER") for _, hub := range hubs { hub.Cleanup() } }() os.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted") testAppFactory := func(t testing.TB) *pbTests.TestApp { hub, _ := beszelTests.NewTestHub(t.TempDir()) hubs = append(hubs, hub) hub.StartHub() return hub.TestApp } scenarios := []beszelTests.ApiScenario{ { Name: "GET /getkey - without trusted header should fail", Method: http.MethodGet, URL: "/api/beszel/getkey", ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /getkey - with trusted header should fail if no matching user", Method: http.MethodGet, URL: "/api/beszel/getkey", Headers: map[string]string{ "X-Beszel-Trusted": "user@test.com", }, ExpectedStatus: 401, ExpectedContent: []string{"requires valid"}, TestAppFactory: testAppFactory, }, { Name: "GET /getkey - with trusted header should succeed", Method: http.MethodGet, URL: "/api/beszel/getkey", Headers: map[string]string{ "X-Beszel-Trusted": "user@test.com", }, ExpectedStatus: 200, ExpectedContent: []string{"\"key\":", "\"v\":"}, TestAppFactory: testAppFactory, BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) { beszelTests.CreateUser(app, "user@test.com", "password123") }, }, } for _, scenario := range scenarios { scenario.Test(t) } } ================================================ FILE: internal/hub/hub_test_helpers.go ================================================ //go:build testing package hub import "github.com/henrygd/beszel/internal/hub/systems" // TESTING ONLY: GetSystemManager returns the system manager func (h *Hub) GetSystemManager() *systems.SystemManager { return h.sm } // TESTING ONLY: GetPubkey returns the public key func (h *Hub) GetPubkey() string { return h.pubKey } // TESTING ONLY: SetPubkey sets the public key func (h *Hub) SetPubkey(pubkey string) { h.pubKey = pubkey } ================================================ FILE: internal/hub/server_development.go ================================================ //go:build development package hub import ( "fmt" "io" "log/slog" "net/http" "net/http/httputil" "net/url" "strings" "github.com/henrygd/beszel" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/osutils" ) // Wraps http.RoundTripper to modify dev proxy HTML responses type responseModifier struct { transport http.RoundTripper hub *Hub } func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := rm.transport.RoundTrip(req) if err != nil { return resp, err } // Only modify HTML responses contentType := resp.Header.Get("Content-Type") if !strings.Contains(contentType, "text/html") { return resp, nil } body, err := io.ReadAll(resp.Body) if err != nil { return resp, err } resp.Body.Close() // Create a new response with the modified body modifiedBody := rm.modifyHTML(string(body)) resp.Body = io.NopCloser(strings.NewReader(modifiedBody)) resp.ContentLength = int64(len(modifiedBody)) resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody))) return resp, nil } func (rm *responseModifier) modifyHTML(html string) string { parsedURL, err := url.Parse(rm.hub.appURL) if err != nil { return html } // fix base paths in html if using subpath basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/" html = strings.ReplaceAll(html, "./", basePath) html = strings.Replace(html, "{{V}}", beszel.Version, 1) html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1) return html } // startServer sets up the development server for Beszel func (h *Hub) startServer(se *core.ServeEvent) error { slog.Info("starting server", "appURL", h.appURL) proxy := httputil.NewSingleHostReverseProxy(&url.URL{ Scheme: "http", Host: "localhost:5173", }) proxy.Transport = &responseModifier{ transport: http.DefaultTransport, hub: h, } se.Router.GET("/{path...}", func(e *core.RequestEvent) error { proxy.ServeHTTP(e.Response, e.Request) return nil }) _ = osutils.LaunchURL(h.appURL) return nil } ================================================ FILE: internal/hub/server_production.go ================================================ //go:build !development package hub import ( "io/fs" "net/http" "net/url" "strings" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/site" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" ) // startServer sets up the production server for Beszel func (h *Hub) startServer(se *core.ServeEvent) error { // parse app url parsedURL, err := url.Parse(h.appURL) if err != nil { return err } // fix base paths in html if using subpath basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/" indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html") html := strings.ReplaceAll(string(indexFile), "./", basePath) html = strings.Replace(html, "{{V}}", beszel.Version, 1) html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1) // set up static asset serving staticPaths := [2]string{"/static/", "/assets/"} serveStatic := apis.Static(site.DistDirFS, false) // get CSP configuration csp, cspExists := GetEnv("CSP") // add route se.Router.GET("/{path...}", func(e *core.RequestEvent) error { // serve static assets if path is in staticPaths for i := range staticPaths { if strings.Contains(e.Request.URL.Path, staticPaths[i]) { e.Response.Header().Set("Cache-Control", "public, max-age=2592000") return serveStatic(e) } } if cspExists { e.Response.Header().Del("X-Frame-Options") e.Response.Header().Set("Content-Security-Policy", csp) } return e.HTML(http.StatusOK, html) }) return nil } ================================================ FILE: internal/hub/systems/system.go ================================================ package systems import ( "context" "encoding/json" "errors" "fmt" "hash/fnv" "math/rand" "net" "strings" "sync/atomic" "time" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/transport" "github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" "github.com/henrygd/beszel" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" "github.com/lxzan/gws" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "golang.org/x/crypto/ssh" ) type System struct { Id string `db:"id"` Host string `db:"host"` Port string `db:"port"` Status string `db:"status"` manager *SystemManager // Manager that this system belongs to client *ssh.Client // SSH client for fetching data sshTransport *transport.SSHTransport // SSH transport for requests data *system.CombinedData // system data from agent ctx context.Context // Context for stopping the updater cancel context.CancelFunc // Stops and removes system from updater WsConn *ws.WsConn // Handler for agent WebSocket connection agentVersion semver.Version // Agent version updateTicker *time.Ticker // Ticker for updating the system detailsFetched atomic.Bool // True if static system details have been fetched and saved smartFetching atomic.Bool // True if SMART devices are currently being fetched smartInterval time.Duration // Interval for periodic SMART data updates } func (sm *SystemManager) NewSystem(systemId string) *System { system := &System{ Id: systemId, data: &system.CombinedData{}, } system.ctx, system.cancel = system.getContext() return system } // StartUpdater starts the system updater. // It first fetches the data from the agent then updates the records. // If the data is not found or the system is down, it sets the system down. func (sys *System) StartUpdater() { // Channel that can be used to set the system down. Currently only used to // allow a short delay for reconnection after websocket connection is closed. var downChan chan struct{} // Add random jitter to first WebSocket connection to prevent // clustering if all agents are started at the same time. // SSH connections during hub startup are already staggered. var jitter <-chan time.Time if sys.WsConn != nil { jitter = getJitter() // use the websocket connection's down channel to set the system down downChan = sys.WsConn.DownChan } else { // if the system does not have a websocket connection, wait before updating // to allow the agent to connect via websocket (makes sure fingerprint is set). time.Sleep(11 * time.Second) } // update immediately if system is not paused (only for ws connections) // we'll wait a minute before connecting via SSH to prioritize ws connections if sys.Status != paused && sys.ctx.Err() == nil { if err := sys.update(); err != nil { _ = sys.setDown(err) } } sys.updateTicker = time.NewTicker(time.Duration(interval) * time.Millisecond) // Go 1.23+ will automatically stop the ticker when the system is garbage collected, however we seem to need this or testing/synctest will block even if calling runtime.GC() defer sys.updateTicker.Stop() for { select { case <-sys.ctx.Done(): return case <-sys.updateTicker.C: if err := sys.update(); err != nil { _ = sys.setDown(err) } case <-downChan: sys.WsConn = nil downChan = nil _ = sys.setDown(nil) case <-jitter: sys.updateTicker.Reset(time.Duration(interval) * time.Millisecond) if err := sys.update(); err != nil { _ = sys.setDown(err) } } } } // update updates the system data and records. func (sys *System) update() error { if sys.Status == paused { sys.handlePaused() return nil } options := common.DataRequestOptions{ CacheTimeMs: uint16(interval), } // fetch system details if not already fetched if !sys.detailsFetched.Load() { options.IncludeDetails = true } data, err := sys.fetchDataFromAgent(options) if err != nil { return err } // ensure deprecated fields from older agents are migrated to current fields migrateDeprecatedFields(data, !sys.detailsFetched.Load()) // create system records _, err = sys.createRecords(data) // if details were included and fetched successfully, mark details as fetched and update smart interval if set by agent if err == nil && data.Details != nil { sys.detailsFetched.Store(true) // update smart interval if it's set on the agent side if data.Details.SmartInterval > 0 { sys.smartInterval = data.Details.SmartInterval // make sure we reset expiration of lastFetch to remain as long as the new smart interval // to prevent premature expiration leading to new fetch if interval is different. sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute) } } // Fetch and save SMART devices when system first comes online or at intervals if backgroundSmartFetchEnabled() && sys.detailsFetched.Load() { if sys.smartInterval <= 0 { sys.smartInterval = time.Hour } lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id) if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) { go func() { defer sys.smartFetching.Store(false) sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute) _ = sys.FetchAndSaveSmartDevices() }() } } return err } func (sys *System) handlePaused() { if sys.WsConn == nil { // if the system is paused and there's no websocket connection, remove the system _ = sys.manager.RemoveSystem(sys.Id) } else { // Send a ping to the agent to keep the connection alive if the system is paused if err := sys.WsConn.Ping(); err != nil { sys.manager.hub.Logger().Warn("Failed to ping agent", "system", sys.Id, "err", err) _ = sys.manager.RemoveSystem(sys.Id) } } } // createRecords updates the system record and adds system_stats and container_stats records func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) { systemRecord, err := sys.getRecord() if err != nil { return nil, err } hub := sys.manager.hub err = hub.RunInTransaction(func(txApp core.App) error { // add system_stats record systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats") if err != nil { return err } systemStatsRecord := core.NewRecord(systemStatsCollection) systemStatsRecord.Set("system", systemRecord.Id) systemStatsRecord.Set("stats", data.Stats) systemStatsRecord.Set("type", "1m") if err := txApp.SaveNoValidate(systemStatsRecord); err != nil { return err } // add containers and container_stats records if len(data.Containers) > 0 { if data.Containers[0].Id != "" { if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil { return err } } containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats") if err != nil { return err } containerStatsRecord := core.NewRecord(containerStatsCollection) containerStatsRecord.Set("system", systemRecord.Id) containerStatsRecord.Set("stats", data.Containers) containerStatsRecord.Set("type", "1m") if err := txApp.SaveNoValidate(containerStatsRecord); err != nil { return err } } // add new systemd_stats record if len(data.SystemdServices) > 0 { if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil { return err } } // add system details record if data.Details != nil { if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil { return err } } // update system record (do this last because it triggers alerts and we need above records to be inserted first) systemRecord.Set("status", up) systemRecord.Set("info", data.Info) if err := txApp.SaveNoValidate(systemRecord); err != nil { return err } return nil }) return systemRecord, err } func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error { collectionName := "system_details" params := dbx.Params{ "id": systemId, "system": systemId, "hostname": data.Hostname, "kernel": data.Kernel, "cores": data.Cores, "threads": data.Threads, "cpu": data.CpuModel, "os": data.Os, "os_name": data.OsName, "arch": data.Arch, "memory": data.MemoryTotal, "podman": data.Podman, "updated": time.Now().UTC(), } result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute() rowsAffected, _ := result.RowsAffected() if err != nil || rowsAffected == 0 { _, err = app.DB().Insert(collectionName, params).Execute() } return err } func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error { if len(data) == 0 { return nil } // shared params for all records params := dbx.Params{ "system": systemId, "updated": time.Now().UTC().UnixMilli(), } valueStrings := make([]string, 0, len(data)) for i, service := range data { suffix := fmt.Sprintf("%d", i) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix)) params["id"+suffix] = makeStableHashId(systemId, service.Name) params["name"+suffix] = service.Name params["state"+suffix] = service.State params["sub"+suffix] = service.Sub params["cpu"+suffix] = service.Cpu params["cpuPeak"+suffix] = service.CpuPeak params["memory"+suffix] = service.Mem params["memPeak"+suffix] = service.MemPeak } queryString := fmt.Sprintf( "INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated", strings.Join(valueStrings, ","), ) _, err := app.DB().NewQuery(queryString).Bind(params).Execute() return err } // createContainerRecords creates container records func createContainerRecords(app core.App, data []*container.Stats, systemId string) error { if len(data) == 0 { return nil } // shared params for all records params := dbx.Params{ "system": systemId, "updated": time.Now().UTC().UnixMilli(), } valueStrings := make([]string, 0, len(data)) for i, container := range data { suffix := fmt.Sprintf("%d", i) valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:ports%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix)) params["id"+suffix] = container.Id params["name"+suffix] = container.Name params["image"+suffix] = container.Image params["ports"+suffix] = container.Ports params["status"+suffix] = container.Status params["health"+suffix] = container.Health params["cpu"+suffix] = container.Cpu params["memory"+suffix] = container.Mem netBytes := container.Bandwidth[0] + container.Bandwidth[1] if netBytes == 0 { netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024) } params["net"+suffix] = netBytes } queryString := fmt.Sprintf( "INSERT INTO containers (id, system, name, image, ports, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, ports = excluded.ports, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated", strings.Join(valueStrings, ","), ) _, err := app.DB().NewQuery(queryString).Bind(params).Execute() return err } // getRecord retrieves the system record from the database. // If the record is not found, it removes the system from the manager. func (sys *System) getRecord() (*core.Record, error) { record, err := sys.manager.hub.FindRecordById("systems", sys.Id) if err != nil || record == nil { _ = sys.manager.RemoveSystem(sys.Id) return nil, err } return record, nil } // setDown marks a system as down in the database. // It takes the original error that caused the system to go down and returns any error // encountered during the process of updating the system status. func (sys *System) setDown(originalError error) error { if sys.Status == down || sys.Status == paused { return nil } record, err := sys.getRecord() if err != nil { return err } if originalError != nil { sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", originalError) } record.Set("status", down) return sys.manager.hub.SaveNoValidate(record) } func (sys *System) getContext() (context.Context, context.CancelFunc) { if sys.ctx == nil { sys.ctx, sys.cancel = context.WithCancel(context.Background()) } return sys.ctx, sys.cancel } // request sends a request to the agent, trying WebSocket first, then SSH. // This is the unified request method that uses the transport abstraction. func (sys *System) request(ctx context.Context, action common.WebSocketAction, req any, dest any) error { // Try WebSocket first if sys.WsConn != nil && sys.WsConn.IsConnected() { wsTransport := transport.NewWebSocketTransport(sys.WsConn) if err := wsTransport.Request(ctx, action, req, dest); err == nil { return nil } else if !shouldFallbackToSSH(err) { return err } else if shouldCloseWebSocket(err) { sys.closeWebSocketConnection() } } // Fall back to SSH if WebSocket fails if err := sys.ensureSSHTransport(); err != nil { return err } err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1) // Keep legacy SSH client/version fields in sync for other code paths. if sys.sshTransport != nil { sys.client = sys.sshTransport.GetClient() sys.agentVersion = sys.sshTransport.GetAgentVersion() } return err } func shouldFallbackToSSH(err error) bool { if err == nil { return false } if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { return true } if errors.Is(err, gws.ErrConnClosed) { return true } return errors.Is(err, transport.ErrWebSocketNotConnected) } func shouldCloseWebSocket(err error) bool { if err == nil { return false } return errors.Is(err, gws.ErrConnClosed) || errors.Is(err, transport.ErrWebSocketNotConnected) } // ensureSSHTransport ensures the SSH transport is initialized and connected. func (sys *System) ensureSSHTransport() error { if sys.sshTransport == nil { if sys.manager.sshConfig == nil { if err := sys.manager.createSSHClientConfig(); err != nil { return err } } sys.sshTransport = transport.NewSSHTransport(transport.SSHTransportConfig{ Host: sys.Host, Port: sys.Port, Config: sys.manager.sshConfig, Timeout: 4 * time.Second, }) } // Sync client state with transport if sys.client != nil { sys.sshTransport.SetClient(sys.client) sys.sshTransport.SetAgentVersion(sys.agentVersion) } return nil } // fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available. func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) { if sys.data == nil { sys.data = &system.CombinedData{} } if sys.WsConn != nil && sys.WsConn.IsConnected() { wsData, err := sys.fetchDataViaWebSocket(options) if err == nil { return wsData, nil } // close the WebSocket connection if error and try SSH sys.closeWebSocketConnection() } sshData, err := sys.fetchDataViaSSH(options) if err != nil { return nil, err } return sshData, nil } func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) { if sys.WsConn == nil || !sys.WsConn.IsConnected() { return nil, errors.New("no websocket connection") } wsTransport := transport.NewWebSocketTransport(sys.WsConn) err := wsTransport.Request(context.Background(), common.GetData, options, sys.data) if err != nil { return nil, err } return sys.data, nil } // FetchContainerInfoFromAgent fetches container info from the agent func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var result string err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result) return result, err } // FetchContainerLogsFromAgent fetches container logs from the agent func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var result string err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result) return result, err } // FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var result systemd.ServiceDetails err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result) return result, err } // FetchSmartDataFromAgent fetches SMART data from the agent func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() var result map[string]smart.SmartData err := sys.request(ctx, common.GetSmartData, nil, &result) return result, err } func makeStableHashId(strings ...string) string { hash := fnv.New32a() for _, str := range strings { hash.Write([]byte(str)) } return fmt.Sprintf("%x", hash.Sum32()) } // fetchDataViaSSH handles fetching data using SSH. // This function encapsulates the original SSH logic. // It updates sys.data directly upon successful fetch. func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) { err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) { stdout, err := session.StdoutPipe() if err != nil { return false, err } stdin, stdinErr := session.StdinPipe() if err := session.Shell(); err != nil { return false, err } *sys.data = system.CombinedData{} if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil { req := common.HubRequest[any]{Action: common.GetData, Data: options} _ = cbor.NewEncoder(stdin).Encode(req) _ = stdin.Close() var resp common.AgentResponse if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil { *sys.data = *resp.SystemData if err := session.Wait(); err != nil { return false, err } return false, nil } } var decodeErr error if sys.agentVersion.GTE(beszel.MinVersionCbor) { decodeErr = cbor.NewDecoder(stdout).Decode(sys.data) } else { decodeErr = json.NewDecoder(stdout).Decode(sys.data) } if decodeErr != nil { return true, decodeErr } if err := session.Wait(); err != nil { return false, err } return false, nil }) if err != nil { return nil, err } return sys.data, nil } // runSSHOperation establishes an SSH session and executes the provided operation. // The operation can request a retry by returning true as the first return value. func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error { for attempt := 0; attempt <= retries; attempt++ { if sys.client == nil || sys.Status == down { if err := sys.createSSHClient(); err != nil { return err } } session, err := sys.createSessionWithTimeout(timeout) if err != nil { if attempt >= retries { return err } sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err) sys.closeSSHConnection() continue } retry, opErr := func() (bool, error) { defer session.Close() return operation(session) }() if opErr == nil { return nil } if retry { sys.closeSSHConnection() if attempt < retries { continue } } return opErr } return fmt.Errorf("ssh operation failed") } // createSSHClient creates a new SSH client for the system func (s *System) createSSHClient() error { if s.manager.sshConfig == nil { if err := s.manager.createSSHClientConfig(); err != nil { return err } } network := "tcp" host := s.Host if strings.HasPrefix(host, "/") { network = "unix" } else { host = net.JoinHostPort(host, s.Port) } var err error s.client, err = ssh.Dial(network, host, s.manager.sshConfig) if err != nil { return err } s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion())) return nil } // createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging // in case of network issues func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) { if sys.client == nil { return nil, fmt.Errorf("client not initialized") } ctx, cancel := context.WithTimeout(sys.ctx, timeout) defer cancel() sessionChan := make(chan *ssh.Session, 1) errChan := make(chan error, 1) go func() { if session, err := sys.client.NewSession(); err != nil { errChan <- err } else { sessionChan <- session } }() select { case session := <-sessionChan: return session, nil case err := <-errChan: return nil, err case <-ctx.Done(): return nil, fmt.Errorf("timeout") } } // closeSSHConnection closes the SSH connection but keeps the system in the manager func (sys *System) closeSSHConnection() { if sys.sshTransport != nil { sys.sshTransport.Close() } if sys.client != nil { sys.client.Close() sys.client = nil } } // closeWebSocketConnection closes the WebSocket connection but keeps the system in the manager // to allow updating via SSH. It will be removed if the WS connection is re-established. // The system will be set as down a few seconds later if the connection is not re-established. func (sys *System) closeWebSocketConnection() { if sys.WsConn != nil { sys.WsConn.Close(nil) } } // extractAgentVersion extracts the beszel version from SSH server version string func extractAgentVersion(versionString string) (semver.Version, error) { _, after, _ := strings.Cut(versionString, "_") return semver.Parse(after) } // getJitter returns a channel that will be triggered after a random delay // between 51% and 95% of the interval. // This is used to stagger the initial WebSocket connections to prevent clustering. func getJitter() <-chan time.Time { minPercent := 51 maxPercent := 95 jitterRange := maxPercent - minPercent msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100) return time.After(time.Duration(msDelay) * time.Millisecond) } // migrateDeprecatedFields moves values from deprecated fields to their new locations if the new // fields are not already populated. Deprecated fields and refs may be removed at least 30 days // and one minor version release after the release that includes the migration. // // This is run when processing incoming system data from agents, which may be on older versions. func migrateDeprecatedFields(cd *system.CombinedData, createDetails bool) { // migration added 0.19.0 if cd.Stats.Bandwidth[0] == 0 && cd.Stats.Bandwidth[1] == 0 { cd.Stats.Bandwidth[0] = uint64(cd.Stats.NetworkSent * 1024 * 1024) cd.Stats.Bandwidth[1] = uint64(cd.Stats.NetworkRecv * 1024 * 1024) cd.Stats.NetworkSent, cd.Stats.NetworkRecv = 0, 0 } // migration added 0.19.0 if cd.Info.BandwidthBytes == 0 { cd.Info.BandwidthBytes = uint64(cd.Info.Bandwidth * 1024 * 1024) cd.Info.Bandwidth = 0 } // migration added 0.19.0 if cd.Stats.DiskIO[0] == 0 && cd.Stats.DiskIO[1] == 0 { cd.Stats.DiskIO[0] = uint64(cd.Stats.DiskReadPs * 1024 * 1024) cd.Stats.DiskIO[1] = uint64(cd.Stats.DiskWritePs * 1024 * 1024) cd.Stats.DiskReadPs, cd.Stats.DiskWritePs = 0, 0 } // migration added 0.19.0 - Move deprecated Info fields to Details struct if cd.Details == nil && cd.Info.Hostname != "" { if createDetails { cd.Details = &system.Details{ Hostname: cd.Info.Hostname, Kernel: cd.Info.KernelVersion, Cores: cd.Info.Cores, Threads: cd.Info.Threads, CpuModel: cd.Info.CpuModel, Podman: cd.Info.Podman, Os: cd.Info.Os, MemoryTotal: uint64(cd.Stats.Mem * 1024 * 1024 * 1024), } } // zero the deprecated fields to prevent saving them in systems.info DB json payload cd.Info.Hostname = "" cd.Info.KernelVersion = "" cd.Info.Cores = 0 cd.Info.CpuModel = "" cd.Info.Podman = false cd.Info.Os = 0 } } ================================================ FILE: internal/hub/systems/system_manager.go ================================================ package systems import ( "errors" "fmt" "time" "github.com/henrygd/beszel/internal/hub/ws" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/hub/expirymap" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel" "github.com/blang/semver" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/store" "golang.org/x/crypto/ssh" ) // System status constants const ( up string = "up" // System is online and responding down string = "down" // System is offline or not responding paused string = "paused" // System monitoring is paused pending string = "pending" // System is waiting on initial connection result // interval is the default update interval in milliseconds (60 seconds) interval int = 60_000 // interval int = 10_000 // Debug interval for faster updates // sessionTimeout is the maximum time to wait for SSH connections sessionTimeout = 4 * time.Second ) // errSystemExists is returned when attempting to add a system that already exists var errSystemExists = errors.New("system exists") // SystemManager manages a collection of monitored systems and their connections. // It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections. type SystemManager struct { hub hubLike // Hub interface for database and alert operations systems *store.Store[string, *System] // Thread-safe store of active systems sshConfig *ssh.ClientConfig // SSH client configuration for system connections smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID } // hubLike defines the interface requirements for the hub dependency. // It extends core.App with system-specific functionality. type hubLike interface { core.App GetSSHKey(dataDir string) (ssh.Signer, error) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error HandleStatusAlerts(status string, systemRecord *core.Record) error } // NewSystemManager creates a new SystemManager instance with the provided hub. // The hub must implement the hubLike interface to provide database and alert functionality. func NewSystemManager(hub hubLike) *SystemManager { return &SystemManager{ systems: store.New(map[string]*System{}), hub: hub, smartFetchMap: expirymap.New[int64](time.Hour), } } // GetSystem returns a system by ID from the store func (sm *SystemManager) GetSystem(systemID string) (*System, error) { sys, ok := sm.systems.GetOk(systemID) if !ok { return nil, fmt.Errorf("system not found") } return sys, nil } // Initialize sets up the system manager by binding event hooks and starting existing systems. // It configures SSH client settings and begins monitoring all non-paused systems from the database. // Systems are started with staggered delays to prevent overwhelming the hub during startup. func (sm *SystemManager) Initialize() error { sm.bindEventHooks() // Initialize SSH client configuration err := sm.createSSHClientConfig() if err != nil { return err } // Load existing systems from database (excluding paused ones) var systems []*System err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems) if err != nil || len(systems) == 0 { return err } // Start systems in background with staggered timing go func() { // Calculate staggered delay between system starts (max 2 seconds per system) delta := interval / max(1, len(systems)) delta = min(delta, 2_000) sleepTime := time.Duration(delta) * time.Millisecond for _, system := range systems { time.Sleep(sleepTime) _ = sm.AddSystem(system) } }() return nil } // bindEventHooks registers event handlers for system and fingerprint record changes. // These hooks ensure the system manager stays synchronized with database changes. func (sm *SystemManager) bindEventHooks() { sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate) sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess) sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate) sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess) sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess) sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated) sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest) sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest) } // onTokenRotated handles fingerprint token rotation events. // When a system's authentication token is rotated, any existing WebSocket connection // must be closed to force re-authentication with the new token. func (sm *SystemManager) onTokenRotated(e *core.RecordEvent) error { systemID := e.Record.GetString("system") system, ok := sm.systems.GetOk(systemID) if !ok { return e.Next() } // No need to close connection if not connected via websocket if system.WsConn == nil { return e.Next() } system.setDown(nil) sm.RemoveSystem(systemID) return e.Next() } // onRecordCreate is called before a new system record is committed to the database. // It initializes the record with default values: empty info and pending status. func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error { e.Record.Set("info", system.Info{}) e.Record.Set("status", pending) return e.Next() } // onRecordAfterCreateSuccess is called after a new system record is successfully created. // It adds the new system to the manager to begin monitoring. func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error { if err := sm.AddRecord(e.Record, nil); err != nil { e.App.Logger().Error("Error adding record", "err", err) } return e.Next() } // onRecordUpdate is called before a system record is updated in the database. // It clears system info when the status is changed to paused. func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error { if e.Record.GetString("status") == paused { e.Record.Set("info", system.Info{}) } return e.Next() } // onRecordAfterUpdateSuccess handles system record updates after they're committed to the database. // It manages system lifecycle based on status changes and triggers appropriate alerts. // Status transitions are handled as follows: // - paused: Closes SSH connection and deactivates alerts // - pending: Starts monitoring (reuses WebSocket if available) // - up: Triggers system alerts // - down: Triggers status change alerts func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error { newStatus := e.Record.GetString("status") prevStatus := pending system, ok := sm.systems.GetOk(e.Record.Id) if ok { prevStatus = system.Status system.Status = newStatus } switch newStatus { case paused: if ok { // Pause monitoring but keep system in manager for potential resume system.closeSSHConnection() } _ = deactivateAlerts(e.App, e.Record.Id) return e.Next() case pending: // Resume monitoring, preferring existing WebSocket connection if ok && system.WsConn != nil { go system.update() return e.Next() } // Start new monitoring session if err := sm.AddRecord(e.Record, nil); err != nil { e.App.Logger().Error("Error adding record", "err", err) } _ = deactivateAlerts(e.App, e.Record.Id) return e.Next() } // Handle systems not in manager if !ok { return sm.AddRecord(e.Record, nil) } // Trigger system alerts when system comes online if newStatus == up { if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil { e.App.Logger().Error("Error handling system alerts", "err", err) } } // Trigger status change alerts for up/down transitions if (newStatus == down && prevStatus == up) || (newStatus == up && prevStatus == down) { if err := sm.hub.HandleStatusAlerts(newStatus, e.Record); err != nil { e.App.Logger().Error("Error handling status alerts", "err", err) } } return e.Next() } // onRecordAfterDeleteSuccess is called after a system record is successfully deleted. // It removes the system from the manager and cleans up all associated resources. func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error { sm.RemoveSystem(e.Record.Id) return e.Next() } // AddSystem adds a system to the manager and starts monitoring it. // It validates required fields, initializes the system context, and starts the update goroutine. // Returns error if a system with the same ID already exists. func (sm *SystemManager) AddSystem(sys *System) error { if sm.systems.Has(sys.Id) { return errSystemExists } if sys.Id == "" || sys.Host == "" { return errors.New("system missing required fields") } // Initialize system for monitoring sys.manager = sm sys.ctx, sys.cancel = sys.getContext() sys.data = &system.CombinedData{} sm.systems.Set(sys.Id, sys) // Start monitoring in background go sys.StartUpdater() return nil } // RemoveSystem removes a system from the manager and cleans up all associated resources. // It cancels the system's context, closes all connections, and removes it from the store. // Returns an error if the system is not found. func (sm *SystemManager) RemoveSystem(systemID string) error { system, ok := sm.systems.GetOk(systemID) if !ok { return errors.New("system not found") } // Stop the update goroutine if system.cancel != nil { system.cancel() } // Clean up all connections system.closeSSHConnection() system.closeWebSocketConnection() sm.systems.Remove(systemID) return nil } // AddRecord creates a System instance from a database record and adds it to the manager. // If a system with the same ID already exists, it's removed first to ensure clean state. // If no system instance is provided, a new one is created. // This method is typically called when systems are created or their status changes to pending. func (sm *SystemManager) AddRecord(record *core.Record, system *System) (err error) { // Remove existing system to ensure clean state if sm.systems.Has(record.Id) { _ = sm.RemoveSystem(record.Id) } // Create new system if none provided if system == nil { system = sm.NewSystem(record.Id) } // Populate system from record system.Status = record.GetString("status") system.Host = record.GetString("host") system.Port = record.GetString("port") return sm.AddSystem(system) } // AddWebSocketSystem creates and adds a system with an established WebSocket connection. // This method is called when an agent connects via WebSocket with valid authentication. // The system is immediately added to monitoring with the provided connection and version info. func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver.Version, wsConn *ws.WsConn) error { systemRecord, err := sm.hub.FindRecordById("systems", systemId) if err != nil { return err } system := sm.NewSystem(systemId) system.WsConn = wsConn system.agentVersion = agentVersion if err := sm.AddRecord(systemRecord, system); err != nil { return err } return nil } // createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server func (sm *SystemManager) createSSHClientConfig() error { privateKey, err := sm.hub.GetSSHKey("") if err != nil { return err } sm.sshConfig = &ssh.ClientConfig{ User: "u", Auth: []ssh.AuthMethod{ ssh.PublicKeys(privateKey), }, Config: ssh.Config{ Ciphers: common.DefaultCiphers, KeyExchanges: common.DefaultKeyExchanges, MACs: common.DefaultMACs, }, HostKeyCallback: ssh.InsecureIgnoreHostKey(), ClientVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), Timeout: sessionTimeout, } return nil } // deactivateAlerts finds all triggered alerts for a system and sets them to inactive. // This is called when a system is paused or goes offline to prevent continued alerts. func deactivateAlerts(app core.App, systemID string) error { // Note: Direct SQL updates don't trigger SSE, so we use the PocketBase API // _, err := app.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", systemID)).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 } ================================================ FILE: internal/hub/systems/system_realtime.go ================================================ package systems import ( "encoding/json" "strings" "sync" "time" "github.com/henrygd/beszel/internal/common" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/subscriptions" ) type subscriptionInfo struct { subscription string connectedClients uint8 } var ( activeSubscriptions = make(map[string]*subscriptionInfo) workerRunning bool realtimeTicker *time.Ticker tickerStopChan chan struct{} realtimeMutex sync.Mutex ) // onRealtimeConnectRequest handles client connection events for realtime subscriptions. // It cleans up existing subscriptions when a client connects. func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequestEvent) error { // after e.Next() is the client disconnection e.Next() subscriptions := e.Client.Subscriptions() for k := range subscriptions { sm.removeRealtimeSubscription(k, subscriptions[k]) } return nil } // onRealtimeSubscribeRequest handles client subscription events for realtime metrics. // It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle. func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error { oldSubs := e.Client.Subscriptions() // after e.Next() is the result of the subscribe request err := e.Next() newSubs := e.Client.Subscriptions() // handle new subscriptions for k, options := range newSubs { if _, ok := oldSubs[k]; !ok { if strings.HasPrefix(k, "rt_metrics") { systemId := options.Query["system"] if _, ok := activeSubscriptions[systemId]; !ok { activeSubscriptions[systemId] = &subscriptionInfo{ subscription: k, } } activeSubscriptions[systemId].connectedClients += 1 sm.onRealtimeSubscriptionAdded() } } } // handle unsubscriptions for k := range oldSubs { if _, ok := newSubs[k]; !ok { sm.removeRealtimeSubscription(k, oldSubs[k]) } } return err } // onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added. // It ensures only one worker runs at a time and creates the ticker for periodic data fetching. func (sm *SystemManager) onRealtimeSubscriptionAdded() { realtimeMutex.Lock() defer realtimeMutex.Unlock() // Start the worker if it's not already running if !workerRunning { workerRunning = true // Create a new stop channel for this worker instance tickerStopChan = make(chan struct{}) go sm.startRealtimeWorker() } // If no ticker exists, create one if realtimeTicker == nil { realtimeTicker = time.NewTicker(1 * time.Second) } } // checkSubscriptions stops the realtime worker when there are no active subscriptions. // This prevents unnecessary resource usage when no clients are listening for realtime data. func (sm *SystemManager) checkSubscriptions() { if !workerRunning || len(activeSubscriptions) > 0 { return } realtimeMutex.Lock() defer realtimeMutex.Unlock() // Signal the worker to stop if tickerStopChan != nil { select { case tickerStopChan <- struct{}{}: default: } } if realtimeTicker != nil { realtimeTicker.Stop() realtimeTicker = nil } // Mark worker as stopped (will be reset when next subscription comes in) workerRunning = false } // removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped. // It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed. func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) { if strings.HasPrefix(subscription, "rt_metrics") { systemId := options.Query["system"] if info, ok := activeSubscriptions[systemId]; ok { info.connectedClients -= 1 if info.connectedClients <= 0 { delete(activeSubscriptions, systemId) } } sm.checkSubscriptions() } } // startRealtimeWorker runs the main loop for fetching realtime data from agents. // It continuously fetches system data and broadcasts it to subscribed clients via WebSocket. func (sm *SystemManager) startRealtimeWorker() { sm.fetchRealtimeDataAndNotify() for { select { case <-tickerStopChan: return case <-realtimeTicker.C: // Check if ticker is still valid (might have been stopped) if realtimeTicker == nil || len(activeSubscriptions) == 0 { return } // slog.Debug("activeSubscriptions", "count", len(activeSubscriptions)) sm.fetchRealtimeDataAndNotify() } } } // fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients. func (sm *SystemManager) fetchRealtimeDataAndNotify() { for systemId, info := range activeSubscriptions { system, err := sm.GetSystem(systemId) if err != nil { continue } go func() { data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000}) if err != nil { return } bytes, err := json.Marshal(data) if err == nil { notify(sm.hub, info.subscription, bytes) } }() } } // notify broadcasts realtime data to all clients subscribed to a specific subscription. // It iterates through all connected clients and sends the data only to those with matching subscriptions. func notify(app core.App, subscription string, data []byte) error { message := subscriptions.Message{ Name: subscription, Data: data, } for _, client := range app.SubscriptionsBroker().Clients() { if !client.HasSubscription(subscription) { continue } client.Send(message) } return nil } ================================================ FILE: internal/hub/systems/system_smart.go ================================================ package systems import ( "database/sql" "errors" "strings" "github.com/henrygd/beszel/internal/entities/smart" "github.com/pocketbase/pocketbase/core" ) // FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database func (sys *System) FetchAndSaveSmartDevices() error { smartData, err := sys.FetchSmartDataFromAgent() if err != nil || len(smartData) == 0 { return err } return sys.saveSmartDevices(smartData) } // saveSmartDevices saves SMART device data to the smart_devices collection func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error { if len(smartData) == 0 { return nil } hub := sys.manager.hub collection, err := hub.FindCachedCollectionByNameOrId("smart_devices") if err != nil { return err } for deviceKey, device := range smartData { if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil { return err } } return nil } func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error { hub := sys.manager.hub recordID := makeStableHashId(sys.Id, deviceKey) record, err := hub.FindRecordById(collection, recordID) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return err } record = core.NewRecord(collection) record.Set("id", recordID) } name := device.DiskName if name == "" { name = deviceKey } powerOnHours, powerCycles := extractPowerMetrics(device.Attributes) record.Set("system", sys.Id) record.Set("name", name) record.Set("model", device.ModelName) record.Set("state", device.SmartStatus) record.Set("capacity", device.Capacity) record.Set("temp", device.Temperature) record.Set("firmware", device.FirmwareVersion) record.Set("serial", device.SerialNumber) record.Set("type", device.DiskType) record.Set("hours", powerOnHours) record.Set("cycles", powerCycles) record.Set("attributes", device.Attributes) return hub.SaveNoValidate(record) } // extractPowerMetrics extracts power on hours and power cycles from SMART attributes func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) { for _, attr := range attributes { nameLower := strings.ToLower(attr.Name) if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) { powerOnHours = attr.RawValue } if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) { powerCycles = attr.RawValue } if powerOnHours > 0 && powerCycles > 0 { break } } return } ================================================ FILE: internal/hub/systems/system_systemd_test.go ================================================ //go:build testing package systems import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetSystemdServiceId(t *testing.T) { t.Run("deterministic output", func(t *testing.T) { systemId := "sys-123" serviceName := "nginx.service" // Call multiple times and ensure same result id1 := makeStableHashId(systemId, serviceName) id2 := makeStableHashId(systemId, serviceName) id3 := makeStableHashId(systemId, serviceName) assert.Equal(t, id1, id2) assert.Equal(t, id2, id3) assert.NotEmpty(t, id1) }) t.Run("different inputs produce different ids", func(t *testing.T) { systemId1 := "sys-123" systemId2 := "sys-456" serviceName1 := "nginx.service" serviceName2 := "apache.service" id1 := makeStableHashId(systemId1, serviceName1) id2 := makeStableHashId(systemId2, serviceName1) id3 := makeStableHashId(systemId1, serviceName2) id4 := makeStableHashId(systemId2, serviceName2) // All IDs should be different assert.NotEqual(t, id1, id2) assert.NotEqual(t, id1, id3) assert.NotEqual(t, id1, id4) assert.NotEqual(t, id2, id3) assert.NotEqual(t, id2, id4) assert.NotEqual(t, id3, id4) }) t.Run("consistent length", func(t *testing.T) { testCases := []struct { systemId string serviceName string }{ {"short", "short.service"}, {"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"}, {"", "empty-system.service"}, {"empty-service", ""}, {"", ""}, } for _, tc := range testCases { id := makeStableHashId(tc.systemId, tc.serviceName) // FNV-32 produces 8 hex characters assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName) } }) t.Run("hexadecimal output", func(t *testing.T) { id := makeStableHashId("test-system", "test-service") assert.NotEmpty(t, id) // Should only contain hexadecimal characters for _, char := range id { assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'), "ID should only contain hexadecimal characters, got: %s", id) } }) } ================================================ FILE: internal/hub/systems/system_test.go ================================================ //go:build testing package systems import ( "testing" "github.com/henrygd/beszel/internal/entities/system" ) func TestCombinedData_MigrateDeprecatedFields(t *testing.T) { t.Run("Migrate NetworkSent and NetworkRecv to Bandwidth", func(t *testing.T) { cd := &system.CombinedData{ Stats: system.Stats{ NetworkSent: 1.5, // 1.5 MB NetworkRecv: 2.5, // 2.5 MB }, } migrateDeprecatedFields(cd, true) expectedSent := uint64(1.5 * 1024 * 1024) expectedRecv := uint64(2.5 * 1024 * 1024) if cd.Stats.Bandwidth[0] != expectedSent { t.Errorf("expected Bandwidth[0] %d, got %d", expectedSent, cd.Stats.Bandwidth[0]) } if cd.Stats.Bandwidth[1] != expectedRecv { t.Errorf("expected Bandwidth[1] %d, got %d", expectedRecv, cd.Stats.Bandwidth[1]) } if cd.Stats.NetworkSent != 0 || cd.Stats.NetworkRecv != 0 { t.Errorf("expected NetworkSent and NetworkRecv to be reset, got %f, %f", cd.Stats.NetworkSent, cd.Stats.NetworkRecv) } }) t.Run("Migrate Info.Bandwidth to Info.BandwidthBytes", func(t *testing.T) { cd := &system.CombinedData{ Info: system.Info{ Bandwidth: 10.0, // 10 MB }, } migrateDeprecatedFields(cd, true) expected := uint64(10 * 1024 * 1024) if cd.Info.BandwidthBytes != expected { t.Errorf("expected BandwidthBytes %d, got %d", expected, cd.Info.BandwidthBytes) } if cd.Info.Bandwidth != 0 { t.Errorf("expected Info.Bandwidth to be reset, got %f", cd.Info.Bandwidth) } }) t.Run("Migrate DiskReadPs and DiskWritePs to DiskIO", func(t *testing.T) { cd := &system.CombinedData{ Stats: system.Stats{ DiskReadPs: 3.0, // 3 MB DiskWritePs: 4.0, // 4 MB }, } migrateDeprecatedFields(cd, true) expectedRead := uint64(3 * 1024 * 1024) expectedWrite := uint64(4 * 1024 * 1024) if cd.Stats.DiskIO[0] != expectedRead { t.Errorf("expected DiskIO[0] %d, got %d", expectedRead, cd.Stats.DiskIO[0]) } if cd.Stats.DiskIO[1] != expectedWrite { t.Errorf("expected DiskIO[1] %d, got %d", expectedWrite, cd.Stats.DiskIO[1]) } if cd.Stats.DiskReadPs != 0 || cd.Stats.DiskWritePs != 0 { t.Errorf("expected DiskReadPs and DiskWritePs to be reset, got %f, %f", cd.Stats.DiskReadPs, cd.Stats.DiskWritePs) } }) t.Run("Migrate Info fields to Details struct", func(t *testing.T) { cd := &system.CombinedData{ Stats: system.Stats{ Mem: 16.0, // 16 GB }, Info: system.Info{ Hostname: "test-host", KernelVersion: "6.8.0", Cores: 8, Threads: 16, CpuModel: "Intel i7", Podman: true, Os: system.Linux, }, } migrateDeprecatedFields(cd, true) if cd.Details == nil { t.Fatal("expected Details struct to be created") } if cd.Details.Hostname != "test-host" { t.Errorf("expected Hostname 'test-host', got '%s'", cd.Details.Hostname) } if cd.Details.Kernel != "6.8.0" { t.Errorf("expected Kernel '6.8.0', got '%s'", cd.Details.Kernel) } if cd.Details.Cores != 8 { t.Errorf("expected Cores 8, got %d", cd.Details.Cores) } if cd.Details.Threads != 16 { t.Errorf("expected Threads 16, got %d", cd.Details.Threads) } if cd.Details.CpuModel != "Intel i7" { t.Errorf("expected CpuModel 'Intel i7', got '%s'", cd.Details.CpuModel) } if cd.Details.Podman != true { t.Errorf("expected Podman true, got %v", cd.Details.Podman) } if cd.Details.Os != system.Linux { t.Errorf("expected Os Linux, got %d", cd.Details.Os) } expectedMem := uint64(16 * 1024 * 1024 * 1024) if cd.Details.MemoryTotal != expectedMem { t.Errorf("expected MemoryTotal %d, got %d", expectedMem, cd.Details.MemoryTotal) } if cd.Info.Hostname != "" || cd.Info.KernelVersion != "" || cd.Info.Cores != 0 || cd.Info.CpuModel != "" || cd.Info.Podman != false || cd.Info.Os != 0 { t.Errorf("expected Info fields to be reset, got %+v", cd.Info) } }) t.Run("Do not migrate if Details already exists", func(t *testing.T) { cd := &system.CombinedData{ Details: &system.Details{Hostname: "existing-host"}, Info: system.Info{ Hostname: "deprecated-host", }, } migrateDeprecatedFields(cd, true) if cd.Details.Hostname != "existing-host" { t.Errorf("expected Hostname 'existing-host', got '%s'", cd.Details.Hostname) } if cd.Info.Hostname != "deprecated-host" { t.Errorf("expected Info.Hostname to remain 'deprecated-host', got '%s'", cd.Info.Hostname) } }) t.Run("Do not create details if migrateDetails is false", func(t *testing.T) { cd := &system.CombinedData{ Info: system.Info{ Hostname: "deprecated-host", }, } migrateDeprecatedFields(cd, false) if cd.Details != nil { t.Fatal("expected Details struct to not be created") } if cd.Info.Hostname != "" { t.Errorf("expected Info.Hostname to be reset, got '%s'", cd.Info.Hostname) } }) } ================================================ FILE: internal/hub/systems/systems_production.go ================================================ //go:build !testing package systems // Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go). // // The hub integration tests create/replace systems and clean up the test apps quickly. // Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB). func backgroundSmartFetchEnabled() bool { return true } ================================================ FILE: internal/hub/systems/systems_test.go ================================================ //go:build testing package systems_test import ( "fmt" "sync" "testing" "testing/synctest" "time" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/hub/systems" "github.com/henrygd/beszel/internal/tests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSystemManagerNew(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) if err != nil { t.Fatal(err) } defer hub.Cleanup() sm := hub.GetSystemManager() user, err := tests.CreateUser(hub, "test@test.com", "testtesttest") require.NoError(t, err) synctest.Test(t, func(t *testing.T) { sm.Initialize() record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "it-was-coney-island", "host": "the-playground-of-the-world", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) assert.Equal(t, "pending", record.GetString("status"), "System status should be 'pending'") assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'") // Verify the system host and port host, port := sm.GetSystemHostPort(record.Id) assert.Equal(t, record.GetString("host"), host, "System host should match") assert.Equal(t, record.GetString("port"), port, "System port should match") time.Sleep(13 * time.Second) synctest.Wait() assert.Equal(t, "pending", record.Fresh().GetString("status"), "System status should be 'pending'") // Verify the system was added by checking if it exists assert.True(t, sm.HasSystem(record.Id), "System should exist in the store") time.Sleep(10 * time.Second) synctest.Wait() // system should be set to down after 15 seconds (no websocket connection) assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'") // make sure the system is down in the db record, err = hub.FindRecordById("systems", record.Id) require.NoError(t, err) assert.Equal(t, "down", record.GetString("status"), "System status should be 'down'") assert.Equal(t, 1, sm.GetSystemCount(), "System count should be 1") err = sm.RemoveSystem(record.Id) assert.NoError(t, err) assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0") assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal") // let's also make sure a system is removed from the store when the record is deleted record, err = tests.CreateRecord(hub, "systems", map[string]any{ "name": "there-was-no-place-like-it", "host": "in-the-whole-world", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) assert.True(t, sm.HasSystem(record.Id), "System should exist in the store after creation") time.Sleep(8 * time.Second) synctest.Wait() assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'") sm.SetSystemStatusInDB(record.Id, "up") time.Sleep(time.Second) synctest.Wait() assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'") // make sure the system switches to down after 11 seconds sm.RemoveSystem(record.Id) sm.AddRecord(record, nil) assert.Equal(t, "pending", sm.GetSystemStatusFromStore(record.Id), "System status should be 'pending'") time.Sleep(12 * time.Second) synctest.Wait() assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'") // sm.SetSystemStatusInDB(record.Id, "paused") // time.Sleep(time.Second) // synctest.Wait() // assert.Equal(t, "paused", sm.GetSystemStatusFromStore(record.Id), "System status should be 'paused'") // delete the record err = hub.Delete(record) require.NoError(t, err) assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion") }) testOld(t, hub) synctest.Test(t, func(t *testing.T) { time.Sleep(time.Second) synctest.Wait() for _, systemId := range sm.GetAllSystemIDs() { err = sm.RemoveSystem(systemId) require.NoError(t, err) assert.False(t, sm.HasSystem(systemId), "System should not exist in the store after deletion") } assert.Equal(t, 0, sm.GetSystemCount(), "System count should be 0") // TODO: test with websocket client }) } func testOld(t *testing.T, hub *tests.TestHub) { user, err := tests.CreateUser(hub, "test@testy.com", "testtesttest") require.NoError(t, err) sm := hub.GetSystemManager() assert.NotNil(t, sm) // error expected when creating a user with a duplicate email _, err = tests.CreateUser(hub, "test@test.com", "testtesttest") require.Error(t, err) // Test collection existence. todo: move to hub package tests t.Run("CollectionExistence", func(t *testing.T) { // Verify that required collections exist systems, err := hub.FindCachedCollectionByNameOrId("systems") require.NoError(t, err) assert.NotNil(t, systems) systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats") require.NoError(t, err) assert.NotNil(t, systemStats) containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats") require.NoError(t, err) assert.NotNil(t, containerStats) }) t.Run("RemoveSystem", func(t *testing.T) { // Get the count before adding the system countBefore := sm.GetSystemCount() // Create a test system record record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "i-even-got-lost-at-coney-island", "host": "but-they-found-me", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Verify the system count increased countAfterAdd := sm.GetSystemCount() assert.Equal(t, countBefore+1, countAfterAdd, "System count should increase after adding a system via event hook") // Verify the system exists assert.True(t, sm.HasSystem(record.Id), "System should exist in the store") // Remove the system err = sm.RemoveSystem(record.Id) assert.NoError(t, err) // Check that the system count decreased countAfterRemove := sm.GetSystemCount() assert.Equal(t, countAfterAdd-1, countAfterRemove, "System count should decrease after removing a system") // Verify the system no longer exists assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal") // Verify the system is not in the list of all system IDs ids := sm.GetAllSystemIDs() assert.NotContains(t, ids, record.Id, "System ID should not be in the list of all system IDs after removal") // Verify the system status is empty status := sm.GetSystemStatusFromStore(record.Id) assert.Equal(t, "", status, "System status should be empty after removal") // Try to remove it again - should return an error since it's already removed err = sm.RemoveSystem(record.Id) assert.Error(t, err) }) t.Run("NewRecordPending", func(t *testing.T) { // Create a test system record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "and-you-know", "host": "i-feel-very-bad", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Add the record to the system manager err = sm.AddRecord(record, nil) require.NoError(t, err) // Test filtering records by status - should be "pending" now filter := "status = 'pending'" pendingSystems, err := hub.FindRecordsByFilter("systems", filter, "-created", 0, 0, nil) require.NoError(t, err) assert.GreaterOrEqual(t, len(pendingSystems), 1) }) t.Run("SystemStatusUpdate", func(t *testing.T) { // Create a test system record record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "we-used-to-sleep-on-the-beach", "host": "sleep-overnight-here", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Add the record to the system manager err = sm.AddRecord(record, nil) require.NoError(t, err) // Test status changes initialStatus := sm.GetSystemStatusFromStore(record.Id) // Set a new status sm.SetSystemStatusInDB(record.Id, "up") // Verify status was updated newStatus := sm.GetSystemStatusFromStore(record.Id) assert.Equal(t, "up", newStatus, "System status should be updated to 'up'") assert.NotEqual(t, initialStatus, newStatus, "Status should have changed") // Verify the database was updated updatedRecord, err := hub.FindRecordById("systems", record.Id) require.NoError(t, err) assert.Equal(t, "up", updatedRecord.Get("status"), "Database status should match") }) t.Run("HandleSystemData", func(t *testing.T) { // Create a test system record record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "things-changed-you-know", "host": "they-dont-sleep-anymore-on-the-beach", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Create test system data testData := &system.CombinedData{ Details: &system.Details{ Hostname: "data-test.example.com", Kernel: "5.15.0-generic", Cores: 4, Threads: 8, CpuModel: "Test CPU", }, Info: system.Info{ Uptime: 3600, Cpu: 25.5, MemPct: 40.2, DiskPct: 60.0, Bandwidth: 100.0, AgentVersion: "1.0.0", }, Stats: system.Stats{ Cpu: 25.5, Mem: 16384.0, MemUsed: 6553.6, MemPct: 40.0, DiskTotal: 1024000.0, DiskUsed: 614400.0, DiskPct: 60.0, NetworkSent: 1024.0, NetworkRecv: 2048.0, }, Containers: []*container.Stats{}, } // Test handling system data. todo: move to hub/alerts package tests err = hub.HandleSystemAlerts(record, testData) assert.NoError(t, err) }) t.Run("ErrorHandling", func(t *testing.T) { // Try to add a non-existent record nonExistentId := "non_existent_id" err := sm.RemoveSystem(nonExistentId) assert.Error(t, err) // Try to add a system with invalid host system := &systems.System{ Host: "", } err = sm.AddSystem(system) assert.Error(t, err) }) t.Run("ConcurrentOperations", func(t *testing.T) { // Create a test system record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "jfkjahkfajs", "host": "localhost", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Run concurrent operations const goroutines = 5 var wg sync.WaitGroup wg.Add(goroutines) for i := range goroutines { go func(i int) { defer wg.Done() // Alternate between different operations switch i % 3 { case 0: status := fmt.Sprintf("status-%d", i) sm.SetSystemStatusInDB(record.Id, status) case 1: _ = sm.GetSystemStatusFromStore(record.Id) case 2: _, _ = sm.GetSystemHostPort(record.Id) } }(i) } wg.Wait() // Verify system still exists and is in a valid state assert.True(t, sm.HasSystem(record.Id), "System should still exist after concurrent operations") status := sm.GetSystemStatusFromStore(record.Id) assert.NotEmpty(t, status, "System should have a status after concurrent operations") }) t.Run("ContextCancellation", func(t *testing.T) { // Create a test system record record, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "lkhsdfsjf", "host": "localhost", "port": "33914", "users": []string{user.Id}, }) require.NoError(t, err) // Verify the system exists in the store assert.True(t, sm.HasSystem(record.Id), "System should exist in the store") // Store the original context and cancel function originalCtx, originalCancel, err := sm.GetSystemContextFromStore(record.Id) assert.NoError(t, err) // Ensure the context is not nil assert.NotNil(t, originalCtx, "System context should not be nil") assert.NotNil(t, originalCancel, "System cancel function should not be nil") // Cancel the context originalCancel() // Wait a short time for cancellation to propagate time.Sleep(10 * time.Millisecond) // Verify the context is done select { case <-originalCtx.Done(): // Context was properly cancelled default: t.Fatal("Context was not cancelled") } // Verify the system is still in the store (cancellation shouldn't remove it) assert.True(t, sm.HasSystem(record.Id), "System should still exist after context cancellation") // Explicitly remove the system err = sm.RemoveSystem(record.Id) assert.NoError(t, err, "RemoveSystem should succeed") // Verify the system is removed assert.False(t, sm.HasSystem(record.Id), "System should be removed after RemoveSystem") // Try to remove it again - should return an error err = sm.RemoveSystem(record.Id) assert.Error(t, err, "RemoveSystem should fail for non-existent system") // Add the system back err = sm.AddRecord(record, nil) require.NoError(t, err, "AddRecord should succeed") // Verify the system is back in the store assert.True(t, sm.HasSystem(record.Id), "System should exist after re-adding") // Verify a new context was created newCtx, newCancel, err := sm.GetSystemContextFromStore(record.Id) assert.NoError(t, err) assert.NotNil(t, newCtx, "New system context should not be nil") assert.NotNil(t, newCancel, "New system cancel function should not be nil") assert.NotEqual(t, originalCtx, newCtx, "New context should be different from original") // Clean up err = sm.RemoveSystem(record.Id) assert.NoError(t, err) }) } ================================================ FILE: internal/hub/systems/systems_test_helpers.go ================================================ //go:build testing package systems import ( "context" "fmt" entities "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/pocketbase/core" ) // The hub integration tests create/replace systems and cleanup the test apps quickly. // Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB). // // We keep the explicit SMART refresh endpoint / method available, but disable // the automatic background fetch during tests. func backgroundSmartFetchEnabled() bool { return false } // TESTING ONLY: GetSystemCount returns the number of systems in the store func (sm *SystemManager) GetSystemCount() int { return sm.systems.Length() } // TESTING ONLY: HasSystem checks if a system with the given ID exists in the store func (sm *SystemManager) HasSystem(systemID string) bool { return sm.systems.Has(systemID) } // TESTING ONLY: GetSystemStatusFromStore returns the status of a system with the given ID // Returns an empty string if the system doesn't exist func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string { sys, ok := sm.systems.GetOk(systemID) if !ok { return "" } return sys.Status } // TESTING ONLY: GetSystemContextFromStore returns the context and cancel function for a system func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) { sys, ok := sm.systems.GetOk(systemID) if !ok { return nil, nil, fmt.Errorf("no system") } return sys.ctx, sys.cancel, nil } // TESTING ONLY: GetSystemFromStore returns a store from the system func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) { sys, ok := sm.systems.GetOk(systemID) if !ok { return nil, fmt.Errorf("no system") } return sys, nil } // TESTING ONLY: GetAllSystemIDs returns a slice of all system IDs in the store func (sm *SystemManager) GetAllSystemIDs() []string { data := sm.systems.GetAll() ids := make([]string, 0, len(data)) for id := range data { ids = append(ids, id) } return ids } // TESTING ONLY: GetSystemData returns the combined data for a system with the given ID // Returns nil if the system doesn't exist // This method is intended for testing func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData { sys, ok := sm.systems.GetOk(systemID) if !ok { return nil } return sys.data } // TESTING ONLY: GetSystemHostPort returns the host and port for a system with the given ID // Returns empty strings if the system doesn't exist func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) { sys, ok := sm.systems.GetOk(systemID) if !ok { return "", "" } return sys.Host, sys.Port } // TESTING ONLY: SetSystemStatusInDB sets the status of a system directly and updates the database record // This is intended for testing // Returns false if the system doesn't exist func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool { if !sm.HasSystem(systemID) { return false } // Update the database record record, err := sm.hub.FindRecordById("systems", systemID) if err != nil { return false } record.Set("status", status) err = sm.hub.Save(record) if err != nil { return false } return true } // TESTING ONLY: RemoveAllSystems removes all systems from the store func (sm *SystemManager) RemoveAllSystems() { for _, system := range sm.systems.GetAll() { sm.RemoveSystem(system.Id) } sm.smartFetchMap.StopCleaner() } func (s *System) StopUpdater() { s.cancel() } func (s *System) CreateRecords(data *entities.CombinedData) (*core.Record, error) { s.data = data return s.createRecords(data) } ================================================ FILE: internal/hub/transport/ssh.go ================================================ package transport import ( "context" "errors" "fmt" "io" "net" "strings" "time" "github.com/blang/semver" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "golang.org/x/crypto/ssh" ) // SSHTransport implements Transport over SSH connections. type SSHTransport struct { client *ssh.Client config *ssh.ClientConfig host string port string agentVersion semver.Version timeout time.Duration } // SSHTransportConfig holds configuration for creating an SSH transport. type SSHTransportConfig struct { Host string Port string Config *ssh.ClientConfig AgentVersion semver.Version Timeout time.Duration } // NewSSHTransport creates a new SSH transport with the given configuration. func NewSSHTransport(cfg SSHTransportConfig) *SSHTransport { timeout := cfg.Timeout if timeout == 0 { timeout = 4 * time.Second } return &SSHTransport{ config: cfg.Config, host: cfg.Host, port: cfg.Port, agentVersion: cfg.AgentVersion, timeout: timeout, } } // SetClient sets the SSH client for reuse across requests. func (t *SSHTransport) SetClient(client *ssh.Client) { t.client = client } // SetAgentVersion sets the agent version (extracted from SSH handshake). func (t *SSHTransport) SetAgentVersion(version semver.Version) { t.agentVersion = version } // GetClient returns the current SSH client (for connection management). func (t *SSHTransport) GetClient() *ssh.Client { return t.client } // GetAgentVersion returns the agent version. func (t *SSHTransport) GetAgentVersion() semver.Version { return t.agentVersion } // Request sends a request to the agent via SSH and unmarshals the response. func (t *SSHTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error { if t.client == nil { if err := t.connect(); err != nil { return err } } session, err := t.createSessionWithTimeout(ctx) if err != nil { return err } defer session.Close() stdout, err := session.StdoutPipe() if err != nil { return err } stdin, err := session.StdinPipe() if err != nil { return err } if err := session.Shell(); err != nil { return err } // Send request hubReq := common.HubRequest[any]{Action: action, Data: req} if err := cbor.NewEncoder(stdin).Encode(hubReq); err != nil { return fmt.Errorf("failed to encode request: %w", err) } stdin.Close() // Read response var resp common.AgentResponse if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil { return fmt.Errorf("failed to decode response: %w", err) } if resp.Error != "" { return errors.New(resp.Error) } if err := session.Wait(); err != nil { return err } return UnmarshalResponse(resp, action, dest) } // IsConnected returns true if the SSH connection is active. func (t *SSHTransport) IsConnected() bool { return t.client != nil } // Close terminates the SSH connection. func (t *SSHTransport) Close() { if t.client != nil { t.client.Close() t.client = nil } } // connect establishes a new SSH connection. func (t *SSHTransport) connect() error { if t.config == nil { return errors.New("SSH config not set") } network := "tcp" host := t.host if strings.HasPrefix(host, "/") { network = "unix" } else { host = net.JoinHostPort(host, t.port) } client, err := ssh.Dial(network, host, t.config) if err != nil { return err } t.client = client // Extract agent version from server version string t.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion())) return nil } // createSessionWithTimeout creates a new SSH session with a timeout. func (t *SSHTransport) createSessionWithTimeout(ctx context.Context) (*ssh.Session, error) { if t.client == nil { return nil, errors.New("client not initialized") } ctx, cancel := context.WithTimeout(ctx, t.timeout) defer cancel() sessionChan := make(chan *ssh.Session, 1) errChan := make(chan error, 1) go func() { session, err := t.client.NewSession() if err != nil { errChan <- err } else { sessionChan <- session } }() select { case session := <-sessionChan: return session, nil case err := <-errChan: return nil, err case <-ctx.Done(): return nil, errors.New("timeout creating session") } } // extractAgentVersion extracts the beszel version from SSH server version string. func extractAgentVersion(versionString string) (semver.Version, error) { _, after, _ := strings.Cut(versionString, "_") return semver.Parse(after) } // RequestWithRetry sends a request with automatic retry on connection failures. func (t *SSHTransport) RequestWithRetry(ctx context.Context, action common.WebSocketAction, req any, dest any, retries int) error { var lastErr error for attempt := 0; attempt <= retries; attempt++ { err := t.Request(ctx, action, req, dest) if err == nil { return nil } lastErr = err // Check if it's a connection error that warrants a retry if isConnectionError(err) && attempt < retries { t.Close() continue } return err } return lastErr } // isConnectionError checks if an error indicates a connection problem. func isConnectionError(err error) bool { if err == nil { return false } errStr := err.Error() return strings.Contains(errStr, "connection") || strings.Contains(errStr, "EOF") || strings.Contains(errStr, "closed") || errors.Is(err, io.EOF) } ================================================ FILE: internal/hub/transport/transport.go ================================================ // Package transport provides a unified abstraction for hub-agent communication // over different transports (WebSocket, SSH). package transport import ( "context" "errors" "fmt" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/entities/smart" "github.com/henrygd/beszel/internal/entities/system" "github.com/henrygd/beszel/internal/entities/systemd" ) // Transport defines the interface for hub-agent communication. // Both WebSocket and SSH transports implement this interface. type Transport interface { // Request sends a request to the agent and unmarshals the response into dest. // The dest parameter should be a pointer to the expected response type. Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error // IsConnected returns true if the transport connection is active. IsConnected() bool // Close terminates the transport connection. Close() } // UnmarshalResponse unmarshals an AgentResponse into the destination type. // It first checks the generic Data field (0.19+ agents), then falls back // to legacy typed fields for backward compatibility with 0.18.0 agents. func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error { if dest == nil { return errors.New("nil destination") } // Try generic Data field first (0.19+) if len(resp.Data) > 0 { if err := cbor.Unmarshal(resp.Data, dest); err != nil { return fmt.Errorf("failed to unmarshal generic response data: %w", err) } return nil } // Fall back to legacy typed fields for older agents/hubs. return unmarshalLegacyResponse(resp, action, dest) } // unmarshalLegacyResponse handles legacy responses that use typed fields. func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error { switch action { case common.GetData: d, ok := dest.(*system.CombinedData) if !ok { return fmt.Errorf("unexpected dest type for GetData: %T", dest) } if resp.SystemData == nil { return errors.New("no system data in response") } *d = *resp.SystemData return nil case common.CheckFingerprint: d, ok := dest.(*common.FingerprintResponse) if !ok { return fmt.Errorf("unexpected dest type for CheckFingerprint: %T", dest) } if resp.Fingerprint == nil { return errors.New("no fingerprint in response") } *d = *resp.Fingerprint return nil case common.GetContainerLogs: d, ok := dest.(*string) if !ok { return fmt.Errorf("unexpected dest type for GetContainerLogs: %T", dest) } if resp.String == nil { return errors.New("no logs in response") } *d = *resp.String return nil case common.GetContainerInfo: d, ok := dest.(*string) if !ok { return fmt.Errorf("unexpected dest type for GetContainerInfo: %T", dest) } if resp.String == nil { return errors.New("no info in response") } *d = *resp.String return nil case common.GetSmartData: d, ok := dest.(*map[string]smart.SmartData) if !ok { return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest) } if resp.SmartData == nil { return errors.New("no SMART data in response") } *d = resp.SmartData return nil case common.GetSystemdInfo: d, ok := dest.(*systemd.ServiceDetails) if !ok { return fmt.Errorf("unexpected dest type for GetSystemdInfo: %T", dest) } if resp.ServiceInfo == nil { return errors.New("no systemd info in response") } *d = resp.ServiceInfo return nil } return fmt.Errorf("unsupported action: %d", action) } ================================================ FILE: internal/hub/transport/websocket.go ================================================ package transport import ( "context" "errors" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/hub/ws" ) // ErrWebSocketNotConnected indicates a WebSocket transport is not currently connected. var ErrWebSocketNotConnected = errors.New("websocket not connected") // WebSocketTransport implements Transport over WebSocket connections. type WebSocketTransport struct { wsConn *ws.WsConn } // NewWebSocketTransport creates a new WebSocket transport wrapper. func NewWebSocketTransport(wsConn *ws.WsConn) *WebSocketTransport { return &WebSocketTransport{wsConn: wsConn} } // Request sends a request to the agent via WebSocket and unmarshals the response. func (t *WebSocketTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error { if !t.IsConnected() { return ErrWebSocketNotConnected } pendingReq, err := t.wsConn.SendRequest(ctx, action, req) if err != nil { return err } // Wait for response select { case message := <-pendingReq.ResponseCh: defer message.Close() defer pendingReq.Cancel() // Legacy agents (< MinVersionAgentResponse) respond with a raw payload instead of an AgentResponse wrapper. if t.wsConn.AgentVersion().LT(beszel.MinVersionAgentResponse) { return cbor.Unmarshal(message.Data.Bytes(), dest) } var agentResponse common.AgentResponse if err := cbor.Unmarshal(message.Data.Bytes(), &agentResponse); err != nil { return err } if agentResponse.Error != "" { return errors.New(agentResponse.Error) } return UnmarshalResponse(agentResponse, action, dest) case <-pendingReq.Context.Done(): return pendingReq.Context.Err() } } // IsConnected returns true if the WebSocket connection is active. func (t *WebSocketTransport) IsConnected() bool { return t.wsConn != nil && t.wsConn.IsConnected() } // Close terminates the WebSocket connection. func (t *WebSocketTransport) Close() { if t.wsConn != nil { t.wsConn.Close(nil) } } ================================================ FILE: internal/hub/update.go ================================================ package hub import ( "fmt" "log" "os" "os/exec" "github.com/henrygd/beszel/internal/ghupdate" "github.com/spf13/cobra" ) // Update updates beszel to the latest version func Update(cmd *cobra.Command, _ []string) { dataDir := os.TempDir() // set dataDir to ./beszel_data if it exists if _, err := os.Stat("./beszel_data"); err == nil { dataDir = "./beszel_data" } // Check if china-mirrors flag is set useMirror, _ := cmd.Flags().GetBool("china-mirrors") // Get the executable path before update exePath, err := os.Executable() if err != nil { log.Fatal(err) } updated, err := ghupdate.Update(ghupdate.Config{ ArchiveExecutable: "beszel", DataDir: dataDir, UseMirror: useMirror, }) if err != nil { log.Fatal(err) } if !updated { return } // make sure the file is executable if err := os.Chmod(exePath, 0755); err != nil { fmt.Printf("Warning: failed to set executable permissions: %v\n", err) } // Fix SELinux context if necessary if err := ghupdate.HandleSELinuxContext(exePath); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) } // Try to restart the service if it's running restartService() } // restartService attempts to restart the beszel service func restartService() { // Check if we're running as a service by looking for systemd if _, err := exec.LookPath("systemctl"); err == nil { // Check if beszel service exists and is active cmd := exec.Command("systemctl", "is-active", "beszel.service") if err := cmd.Run(); err == nil { ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...") restartCmd := exec.Command("systemctl", "restart", "beszel.service") if err := restartCmd.Run(); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err) ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo systemctl restart beszel") } else { ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully") } return } } // Check for OpenRC (Alpine Linux) if _, err := exec.LookPath("rc-service"); err == nil { cmd := exec.Command("rc-service", "beszel", "status") if err := cmd.Run(); err == nil { ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel service...") restartCmd := exec.Command("rc-service", "beszel", "restart") if err := restartCmd.Run(); err != nil { ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: Failed to restart service: %v\n", err) ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually: sudo rc-service beszel restart") } else { ghupdate.ColorPrint(ghupdate.ColorGreen, "Service restarted successfully") } return } } ghupdate.ColorPrint(ghupdate.ColorYellow, "Service restart not attempted. If running as a service, restart manually.") } ================================================ FILE: internal/hub/ws/handlers.go ================================================ package ws import ( "context" "errors" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/lxzan/gws" "golang.org/x/crypto/ssh" ) // ResponseHandler defines interface for handling agent responses. // This is used by handleAgentRequest for legacy response handling. type ResponseHandler interface { Handle(agentResponse common.AgentResponse) error HandleLegacy(rawData []byte) error } // BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional type BaseHandler struct{} func (h *BaseHandler) HandleLegacy(rawData []byte) error { return errors.New("legacy format not supported") } //////////////////////////////////////////////////////////////////////////// // Fingerprint handling (used for WebSocket authentication) //////////////////////////////////////////////////////////////////////////// // fingerprintHandler implements ResponseHandler for fingerprint requests type fingerprintHandler struct { result *common.FingerprintResponse } func (h *fingerprintHandler) HandleLegacy(rawData []byte) error { return cbor.Unmarshal(rawData, h.result) } func (h *fingerprintHandler) Handle(agentResponse common.AgentResponse) error { if agentResponse.Fingerprint != nil { *h.result = *agentResponse.Fingerprint return nil } return errors.New("no fingerprint data in response") } // GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint. func (ws *WsConn) GetFingerprint(ctx context.Context, token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) { if !ws.IsConnected() { return common.FingerprintResponse{}, gws.ErrConnClosed } challenge := []byte(token) signature, err := signer.Sign(nil, challenge) if err != nil { return common.FingerprintResponse{}, err } req, err := ws.requestManager.SendRequest(ctx, common.CheckFingerprint, common.FingerprintRequest{ Signature: signature.Blob, NeedSysInfo: needSysInfo, }) if err != nil { return common.FingerprintResponse{}, err } var result common.FingerprintResponse handler := &fingerprintHandler{result: &result} err = ws.handleAgentRequest(req, handler) return result, err } ================================================ FILE: internal/hub/ws/request_manager.go ================================================ package ws import ( "context" "fmt" "sync" "sync/atomic" "time" "github.com/fxamacker/cbor/v2" "github.com/henrygd/beszel/internal/common" "github.com/lxzan/gws" ) // RequestID uniquely identifies a request type RequestID uint32 // PendingRequest tracks an in-flight request type PendingRequest struct { ID RequestID ResponseCh chan *gws.Message Context context.Context Cancel context.CancelFunc CreatedAt time.Time } // RequestManager handles concurrent requests to an agent type RequestManager struct { sync.RWMutex conn *gws.Conn pendingReqs map[RequestID]*PendingRequest nextID atomic.Uint32 } // NewRequestManager creates a new request manager for a WebSocket connection func NewRequestManager(conn *gws.Conn) *RequestManager { rm := &RequestManager{ conn: conn, pendingReqs: make(map[RequestID]*PendingRequest), } return rm } // SendRequest sends a request and returns a channel for the response func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) { reqID := RequestID(rm.nextID.Add(1)) // Respect any caller-provided deadline. If none is set, apply a reasonable default // so pending requests don't live forever if the agent never responds. reqCtx := ctx var cancel context.CancelFunc if _, hasDeadline := ctx.Deadline(); hasDeadline { reqCtx, cancel = context.WithCancel(ctx) } else { reqCtx, cancel = context.WithTimeout(ctx, 5*time.Second) } req := &PendingRequest{ ID: reqID, ResponseCh: make(chan *gws.Message, 1), Context: reqCtx, Cancel: cancel, CreatedAt: time.Now(), } rm.Lock() rm.pendingReqs[reqID] = req rm.Unlock() hubReq := common.HubRequest[any]{ Id: (*uint32)(&reqID), Action: action, Data: data, } // Send the request if err := rm.sendMessage(hubReq); err != nil { rm.cancelRequest(reqID) return nil, fmt.Errorf("failed to send request: %w", err) } // Start cleanup watcher for timeout/cancellation go rm.cleanupRequest(req) return req, nil } // sendMessage encodes and sends a message over WebSocket func (rm *RequestManager) sendMessage(data any) error { if rm.conn == nil { return gws.ErrConnClosed } bytes, err := cbor.Marshal(data) if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } return rm.conn.WriteMessage(gws.OpcodeBinary, bytes) } // handleResponse processes a single response message func (rm *RequestManager) handleResponse(message *gws.Message) { var response common.AgentResponse if err := cbor.Unmarshal(message.Data.Bytes(), &response); err != nil { // Legacy response without ID - route to first pending request of any type rm.routeLegacyResponse(message) return } if response.Id == nil { rm.routeLegacyResponse(message) return } reqID := RequestID(*response.Id) rm.RLock() req, exists := rm.pendingReqs[reqID] rm.RUnlock() if !exists { // Request not found (might have timed out) - close the message message.Close() return } select { case req.ResponseCh <- message: // Message successfully delivered - the receiver will close it rm.deleteRequest(reqID) case <-req.Context.Done(): // Request was cancelled/timed out - close the message message.Close() } } // routeLegacyResponse handles responses that don't have request IDs (backwards compatibility) func (rm *RequestManager) routeLegacyResponse(message *gws.Message) { // Snapshot the oldest pending request without holding the lock during send rm.RLock() var oldestReq *PendingRequest for _, req := range rm.pendingReqs { if oldestReq == nil || req.CreatedAt.Before(oldestReq.CreatedAt) { oldestReq = req } } rm.RUnlock() if oldestReq != nil { select { case oldestReq.ResponseCh <- message: // Message successfully delivered - the receiver will close it rm.deleteRequest(oldestReq.ID) case <-oldestReq.Context.Done(): // Request was cancelled - close the message message.Close() } } else { // No pending requests - close the message message.Close() } } // cleanupRequest handles request timeout and cleanup func (rm *RequestManager) cleanupRequest(req *PendingRequest) { <-req.Context.Done() rm.cancelRequest(req.ID) } // cancelRequest removes a request and cancels its context func (rm *RequestManager) cancelRequest(reqID RequestID) { rm.Lock() defer rm.Unlock() if req, exists := rm.pendingReqs[reqID]; exists { req.Cancel() delete(rm.pendingReqs, reqID) } } // deleteRequest removes a request from the pending map without cancelling its context. func (rm *RequestManager) deleteRequest(reqID RequestID) { rm.Lock() defer rm.Unlock() delete(rm.pendingReqs, reqID) } // Close shuts down the request manager func (rm *RequestManager) Close() { rm.Lock() defer rm.Unlock() // Cancel all pending requests for _, req := range rm.pendingReqs { req.Cancel() } rm.pendingReqs = make(map[RequestID]*PendingRequest) } ================================================ FILE: internal/hub/ws/request_manager_test.go ================================================ //go:build testing package ws import ( "context" "testing" "time" "github.com/stretchr/testify/assert" ) // TestRequestManager_BasicFunctionality tests the request manager without mocking gws.Conn func TestRequestManager_BasicFunctionality(t *testing.T) { // We'll test the core logic without mocking the connection // since the gws.Conn interface is complex to mock properly t.Run("request ID generation", func(t *testing.T) { // Test that request IDs are generated sequentially and uniquely rm := &RequestManager{} // Simulate multiple ID generations id1 := rm.nextID.Add(1) id2 := rm.nextID.Add(1) id3 := rm.nextID.Add(1) assert.NotEqual(t, id1, id2) assert.NotEqual(t, id2, id3) assert.Greater(t, id2, id1) assert.Greater(t, id3, id2) }) t.Run("pending request tracking", func(t *testing.T) { rm := &RequestManager{ pendingReqs: make(map[RequestID]*PendingRequest), } // Initially no pending requests assert.Equal(t, 0, rm.GetPendingCount()) // Add some fake pending requests ctx, cancel := context.WithCancel(context.Background()) defer cancel() req1 := &PendingRequest{ ID: RequestID(1), Context: ctx, Cancel: cancel, } req2 := &PendingRequest{ ID: RequestID(2), Context: ctx, Cancel: cancel, } rm.pendingReqs[req1.ID] = req1 rm.pendingReqs[req2.ID] = req2 assert.Equal(t, 2, rm.GetPendingCount()) // Remove one delete(rm.pendingReqs, req1.ID) assert.Equal(t, 1, rm.GetPendingCount()) // Remove all delete(rm.pendingReqs, req2.ID) assert.Equal(t, 0, rm.GetPendingCount()) }) t.Run("context cancellation", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) defer cancel() // Wait for context to timeout <-ctx.Done() // Verify context was cancelled assert.Equal(t, context.DeadlineExceeded, ctx.Err()) }) } ================================================ FILE: internal/hub/ws/ws.go ================================================ package ws import ( "context" "errors" "time" "weak" "github.com/blang/semver" "github.com/henrygd/beszel" "github.com/henrygd/beszel/internal/common" "github.com/fxamacker/cbor/v2" "github.com/lxzan/gws" ) const ( deadline = 70 * time.Second ) // Handler implements the WebSocket event handler for agent connections. type Handler struct { gws.BuiltinEventHandler } // WsConn represents a WebSocket connection to an agent. type WsConn struct { conn *gws.Conn requestManager *RequestManager DownChan chan struct{} agentVersion semver.Version } // FingerprintRecord is fingerprints collection record data in the hub type FingerprintRecord struct { Id string `db:"id"` SystemId string `db:"system"` Fingerprint string `db:"fingerprint"` Token string `db:"token"` } var upgrader *gws.Upgrader // GetUpgrader returns a singleton WebSocket upgrader instance. func GetUpgrader() *gws.Upgrader { if upgrader != nil { return upgrader } handler := &Handler{} upgrader = gws.NewUpgrader(handler, &gws.ServerOption{}) return upgrader } // NewWsConnection creates a new WebSocket connection wrapper with agent version. func NewWsConnection(conn *gws.Conn, agentVersion semver.Version) *WsConn { return &WsConn{ conn: conn, requestManager: NewRequestManager(conn), DownChan: make(chan struct{}, 1), agentVersion: agentVersion, } } // OnOpen sets a deadline for the WebSocket connection and extracts agent version. func (h *Handler) OnOpen(conn *gws.Conn) { conn.SetDeadline(time.Now().Add(deadline)) } // OnMessage routes incoming WebSocket messages to the request manager. func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) { conn.SetDeadline(time.Now().Add(deadline)) if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 { return } wsConn, ok := conn.Session().Load("wsConn") if !ok { _ = conn.WriteClose(1000, nil) return } wsConn.(*WsConn).requestManager.handleResponse(message) } // OnClose handles WebSocket connection closures and triggers system down status after delay. func (h *Handler) OnClose(conn *gws.Conn, err error) { wsConn, ok := conn.Session().Load("wsConn") if !ok { return } wsConn.(*WsConn).conn = nil // wait 5 seconds to allow reconnection before setting system down // use a weak pointer to avoid keeping references if the system is removed go func(downChan weak.Pointer[chan struct{}]) { time.Sleep(5 * time.Second) downChanValue := downChan.Value() if downChanValue != nil { *downChanValue <- struct{}{} } }(weak.Make(&wsConn.(*WsConn).DownChan)) } // Close terminates the WebSocket connection gracefully. func (ws *WsConn) Close(msg []byte) { if ws.IsConnected() { ws.conn.WriteClose(1000, msg) } if ws.requestManager != nil { ws.requestManager.Close() } } // Ping sends a ping frame to keep the connection alive. func (ws *WsConn) Ping() error { ws.conn.SetDeadline(time.Now().Add(deadline)) return ws.conn.WritePing(nil) } // sendMessage encodes data to CBOR and sends it as a binary message to the agent. // This is kept for backwards compatibility but new actions should use RequestManager. func (ws *WsConn) sendMessage(data common.HubRequest[any]) error { if ws.conn == nil { return gws.ErrConnClosed } bytes, err := cbor.Marshal(data) if err != nil { return err } return ws.conn.WriteMessage(gws.OpcodeBinary, bytes) } // handleAgentRequest processes a request to the agent, handling both legacy and new formats. func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandler) error { // Wait for response select { case message := <-req.ResponseCh: defer message.Close() // Cancel request context to stop timeout watcher promptly defer req.Cancel() data := message.Data.Bytes() // Legacy format - unmarshal directly if ws.agentVersion.LT(beszel.MinVersionAgentResponse) { return handler.HandleLegacy(data) } // New format with AgentResponse wrapper var agentResponse common.AgentResponse if err := cbor.Unmarshal(data, &agentResponse); err != nil { return err } if agentResponse.Error != "" { return errors.New(agentResponse.Error) } return handler.Handle(agentResponse) case <-req.Context.Done(): return req.Context.Err() } } // IsConnected returns true if the WebSocket connection is active. func (ws *WsConn) IsConnected() bool { return ws.conn != nil } // AgentVersion returns the connected agent's version (as reported during handshake). func (ws *WsConn) AgentVersion() semver.Version { return ws.agentVersion } // SendRequest sends a request to the agent and returns a pending request handle. // This is used by the transport layer to send requests. func (ws *WsConn) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) { return ws.requestManager.SendRequest(ctx, action, data) } ================================================ FILE: internal/hub/ws/ws_test.go ================================================ //go:build testing package ws import ( "crypto/ed25519" "testing" "time" "github.com/blang/semver" "github.com/henrygd/beszel/internal/common" "github.com/fxamacker/cbor/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" ) // TestGetUpgrader tests the singleton upgrader func TestGetUpgrader(t *testing.T) { // Reset the global upgrader to test singleton behavior upgrader = nil // First call should create the upgrader upgrader1 := GetUpgrader() assert.NotNil(t, upgrader1, "Upgrader should not be nil") // Second call should return the same instance upgrader2 := GetUpgrader() assert.Same(t, upgrader1, upgrader2, "Should return the same upgrader instance") // Verify it's properly configured assert.NotNil(t, upgrader1, "Upgrader should be configured") } // TestNewWsConnection tests WebSocket connection creation func TestNewWsConnection(t *testing.T) { // We can't easily mock gws.Conn, so we'll pass nil and test the structure wsConn := NewWsConnection(nil, semver.MustParse("0.12.10")) assert.NotNil(t, wsConn, "WebSocket connection should not be nil") assert.Nil(t, wsConn.conn, "Connection should be nil as passed") assert.NotNil(t, wsConn.requestManager, "Request manager should be initialized") assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized") assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1") } // TestWsConn_IsConnected tests the connection status check func TestWsConn_IsConnected(t *testing.T) { // Test with nil connection wsConn := NewWsConnection(nil, semver.MustParse("0.12.10")) assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil") } // TestWsConn_Close tests the connection closing with nil connection func TestWsConn_Close(t *testing.T) { wsConn := NewWsConnection(nil, semver.MustParse("0.12.10")) // Should handle nil connection gracefully assert.NotPanics(t, func() { wsConn.Close([]byte("test message")) }, "Should not panic when closing nil connection") } // TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage func TestWsConn_SendMessage_CBOR(t *testing.T) { wsConn := NewWsConnection(nil, semver.MustParse("0.12.10")) testData := common.HubRequest[any]{ Action: common.GetData, Data: "test data", } // This will fail because conn is nil, but we can test the CBOR encoding logic // by checking that the function properly encodes to CBOR before failing err := wsConn.sendMessage(testData) assert.Error(t, err, "Should error with nil connection") // Test CBOR encoding separately bytes, err := cbor.Marshal(testData) assert.NoError(t, err, "Should encode to CBOR successfully") // Verify we can decode it back var decodedData common.HubRequest[any] err = cbor.Unmarshal(bytes, &decodedData) assert.NoError(t, err, "Should decode from CBOR successfully") assert.Equal(t, testData.Action, decodedData.Action, "Action should match") } // TestWsConn_GetFingerprint_SignatureGeneration tests signature creation logic func TestWsConn_GetFingerprint_SignatureGeneration(t *testing.T) { // Generate test key pair _, privKey, err := ed25519.GenerateKey(nil) require.NoError(t, err) signer, err := ssh.NewSignerFromKey(privKey) require.NoError(t, err) token := "test-token" // This will timeout since conn is nil, but we can verify the signature logic // We can't test the full flow, but we can test that the signature is created properly challenge := []byte(token) signature, err := signer.Sign(nil, challenge) assert.NoError(t, err, "Should create signature successfully") assert.NotEmpty(t, signature.Blob, "Signature blob should not be empty") assert.Equal(t, signer.PublicKey().Type(), signature.Format, "Signature format should match key type") // Test the fingerprint request structure fpRequest := common.FingerprintRequest{ Signature: signature.Blob, NeedSysInfo: true, } // Test CBOR encoding of fingerprint request fpData, err := cbor.Marshal(fpRequest) assert.NoError(t, err, "Should encode fingerprint request to CBOR") var decodedFpRequest common.FingerprintRequest err = cbor.Unmarshal(fpData, &decodedFpRequest) assert.NoError(t, err, "Should decode fingerprint request from CBOR") assert.Equal(t, fpRequest.Signature, decodedFpRequest.Signature, "Signature should match") assert.Equal(t, fpRequest.NeedSysInfo, decodedFpRequest.NeedSysInfo, "NeedSysInfo should match") // Test the full hub request structure hubRequest := common.HubRequest[any]{ Action: common.CheckFingerprint, Data: fpRequest, } hubData, err := cbor.Marshal(hubRequest) assert.NoError(t, err, "Should encode hub request to CBOR") var decodedHubRequest common.HubRequest[cbor.RawMessage] err = cbor.Unmarshal(hubData, &decodedHubRequest) assert.NoError(t, err, "Should decode hub request from CBOR") assert.Equal(t, common.CheckFingerprint, decodedHubRequest.Action, "Action should be CheckFingerprint") } // TestWsConn_RequestSystemData_RequestFormat tests system data request format func TestWsConn_RequestSystemData_RequestFormat(t *testing.T) { // Test the request format that would be sent request := common.HubRequest[any]{ Action: common.GetData, } // Test CBOR encoding data, err := cbor.Marshal(request) assert.NoError(t, err, "Should encode request to CBOR") // Test decoding var decodedRequest common.HubRequest[any] err = cbor.Unmarshal(data, &decodedRequest) assert.NoError(t, err, "Should decode request from CBOR") assert.Equal(t, common.GetData, decodedRequest.Action, "Should have GetData action") } // TestFingerprintRecord tests the FingerprintRecord struct func TestFingerprintRecord(t *testing.T) { record := FingerprintRecord{ Id: "test-id", SystemId: "system-123", Fingerprint: "test-fingerprint", Token: "test-token", } assert.Equal(t, "test-id", record.Id) assert.Equal(t, "system-123", record.SystemId) assert.Equal(t, "test-fingerprint", record.Fingerprint) assert.Equal(t, "test-token", record.Token) } // TestDeadlineConstant tests that the deadline constant is reasonable func TestDeadlineConstant(t *testing.T) { assert.Equal(t, 70*time.Second, deadline, "Deadline should be 70 seconds") } // TestCommonActions tests that the common actions are properly defined func TestCommonActions(t *testing.T) { // Test that the actions we use exist and have expected values assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0") assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1") assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2") } func TestFingerprintHandler(t *testing.T) { var result common.FingerprintResponse h := &fingerprintHandler{result: &result} resp := common.AgentResponse{Fingerprint: &common.FingerprintResponse{ Fingerprint: "test-fingerprint", Hostname: "test-host", }} err := h.Handle(resp) assert.NoError(t, err) assert.Equal(t, "test-fingerprint", result.Fingerprint) assert.Equal(t, "test-host", result.Hostname) } // TestHandler tests that we can create a Handler func TestHandler(t *testing.T) { handler := &Handler{} assert.NotNil(t, handler, "Handler should be created successfully") // The Handler embeds gws.BuiltinEventHandler, so it should have the embedded type assert.NotNil(t, handler.BuiltinEventHandler, "Should have embedded BuiltinEventHandler") } // TestWsConnChannelBehavior tests channel behavior without WebSocket connections func TestWsConnChannelBehavior(t *testing.T) { wsConn := NewWsConnection(nil, semver.MustParse("0.12.10")) // Test that channels are properly initialized and can be used select { case wsConn.DownChan <- struct{}{}: // Should be able to write to channel default: t.Error("Should be able to write to DownChan") } // Test reading from DownChan select { case <-wsConn.DownChan: // Should be able to read from channel case <-time.After(10 * time.Millisecond): t.Error("Should be able to read from DownChan") } // Request manager should have no pending requests initially assert.Equal(t, 0, wsConn.requestManager.GetPendingCount(), "Should have no pending requests initially") } ================================================ FILE: internal/hub/ws/ws_test_helpers.go ================================================ //go:build testing package ws // GetPendingCount returns the number of pending requests (for monitoring) func (rm *RequestManager) GetPendingCount() int { rm.RLock() defer rm.RUnlock() return len(rm.pendingReqs) } ================================================ FILE: internal/migrations/0_collections_snapshot_0_19_0_dev_1.go ================================================ package migrations import ( "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" ) func init() { m.Register(func(app core.App) error { // update collections jsonData := `[ { "id": "elngm8x1l60zi2v", "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": "", "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "name": "alerts", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "hn5ly3vi", "maxSelect": 1, "minSelect": 0, "name": "user", "presentable": false, "required": true, "system": false, "type": "relation" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "g5sl3jdg", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "hidden": false, "id": "zj3ingrv", "maxSelect": 1, "name": "name", "presentable": false, "required": true, "system": false, "type": "select", "values": [ "Status", "CPU", "Memory", "Disk", "Temperature", "Bandwidth", "GPU", "LoadAvg1", "LoadAvg5", "LoadAvg15", "Battery" ] }, { "hidden": false, "id": "o2ablxvn", "max": null, "min": null, "name": "value", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "fstdehcq", "max": 60, "min": null, "name": "min", "onlyInt": true, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "6hgdf6hs", "name": "triggered", "presentable": false, "required": false, "system": false, "type": "bool" }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE UNIQUE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `,\n ` + "`" + `name` + "`" + `\n)" ], "system": false }, { "id": "pbc_1697146157", "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "createRule": null, "updateRule": null, "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "name": "alerts_history", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "relation2375276105", "maxSelect": 1, "minSelect": 0, "name": "user", "presentable": false, "required": true, "system": false, "type": "relation" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "autogeneratePattern": "", "hidden": false, "id": "text2466471794", "max": 0, "min": 0, "name": "alert_id", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text1579384326", "max": 0, "min": 0, "name": "name", "pattern": "", "presentable": false, "primaryKey": false, "required": true, "system": false, "type": "text" }, { "hidden": false, "id": "number494360628", "max": null, "min": null, "name": "value", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "date2276568630", "max": "", "min": "", "name": "resolved", "presentable": false, "required": false, "system": false, "type": "date" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_YdGnup5aqB` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `user` + "`" + `)", "CREATE INDEX ` + "`" + `idx_taLet9VdME` + "`" + ` ON ` + "`" + `alerts_history` + "`" + ` (` + "`" + `created` + "`" + `)" ], "system": false }, { "id": "juohu4jipgc13v7", "listRule": "@request.auth.id != \"\"", "viewRule": null, "createRule": null, "updateRule": null, "deleteRule": null, "name": "container_stats", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "hutcu6ps", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "hidden": false, "id": "r39hhnil", "maxSize": 2000000, "name": "stats", "presentable": false, "required": true, "system": false, "type": "json" }, { "hidden": false, "id": "vo7iuj96", "maxSelect": 1, "name": "type", "presentable": false, "required": true, "system": false, "type": "select", "values": [ "1m", "10m", "20m", "120m", "480m" ] }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_d87OiXGZD8` + "`" + ` ON ` + "`" + `container_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)" ], "system": false }, { "id": "pbc_3663931638", "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "createRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", "updateRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", "deleteRule": null, "name": "fingerprints", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{9}", "hidden": false, "id": "text3208210256", "max": 15, "min": 9, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "autogeneratePattern": "[a-zA-Z9-9]{20}", "hidden": false, "id": "text1597481275", "max": 255, "min": 9, "name": "token", "pattern": "", "presentable": false, "primaryKey": false, "required": true, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text4228609354", "max": 255, "min": 9, "name": "fingerprint", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_p9qZlu26po` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `token` + "`" + `)", "CREATE UNIQUE INDEX ` + "`" + `idx_ngboulGMYw` + "`" + ` ON ` + "`" + `fingerprints` + "`" + ` (` + "`" + `system` + "`" + `)" ], "system": false }, { "id": "ej9oowivz8b2mht", "listRule": "@request.auth.id != \"\"", "viewRule": null, "createRule": null, "updateRule": null, "deleteRule": null, "name": "system_stats", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "h9sg148r", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "hidden": false, "id": "azftn0be", "maxSize": 2000000, "name": "stats", "presentable": false, "required": true, "system": false, "type": "json" }, { "hidden": false, "id": "m1ekhli3", "maxSelect": 1, "name": "type", "presentable": false, "required": true, "system": false, "type": "select", "values": [ "1m", "10m", "20m", "120m", "480m" ] }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)" ], "system": false }, { "id": "4afacsdnlu8q8r2", "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": null, "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": null, "name": "user_settings", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "d5vztyxa", "maxSelect": 1, "minSelect": 0, "name": "user", "presentable": false, "required": true, "system": false, "type": "relation" }, { "hidden": false, "id": "xcx4qgqq", "maxSize": 2000000, "name": "settings", "presentable": false, "required": false, "system": false, "type": "json" }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)" ], "system": false }, { "id": "2hz5ncl8tizk5nx", "listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id", "viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id", "createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", "updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", "deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"", "name": "systems", "type": "base", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "7xloxkwk", "max": 0, "min": 0, "name": "name", "pattern": "", "presentable": false, "primaryKey": false, "required": true, "system": false, "type": "text" }, { "hidden": false, "id": "waj7seaf", "maxSelect": 1, "name": "status", "presentable": false, "required": false, "system": false, "type": "select", "values": [ "up", "down", "paused", "pending" ] }, { "autogeneratePattern": "", "hidden": false, "id": "ve781smf", "max": 0, "min": 0, "name": "host", "pattern": "", "presentable": false, "primaryKey": false, "required": true, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "pij0k2jk", "max": 0, "min": 0, "name": "port", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "qoq64ntl", "maxSize": 2000000, "name": "info", "presentable": false, "required": false, "system": false, "type": "json" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "jcarjnjj", "maxSelect": 2147483647, "minSelect": 0, "name": "users", "presentable": false, "required": true, "system": false, "type": "relation" }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_systems_status` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `status` + "`" + `)" ], "system": false }, { "id": "_pb_users_auth_", "listRule": "id = @request.auth.id", "viewRule": "id = @request.auth.id", "createRule": null, "updateRule": null, "deleteRule": null, "name": "users", "type": "auth", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cost": 10, "hidden": true, "id": "password901924565", "max": 0, "min": 8, "name": "password", "pattern": "", "presentable": false, "required": true, "system": true, "type": "password" }, { "autogeneratePattern": "[a-zA-Z0-9_]{50}", "hidden": true, "id": "text2504183744", "max": 60, "min": 30, "name": "tokenKey", "pattern": "", "presentable": false, "primaryKey": false, "required": true, "system": true, "type": "text" }, { "exceptDomains": null, "hidden": false, "id": "email3885137012", "name": "email", "onlyDomains": null, "presentable": false, "required": true, "system": true, "type": "email" }, { "hidden": false, "id": "bool1547992806", "name": "emailVisibility", "presentable": false, "required": false, "system": true, "type": "bool" }, { "hidden": false, "id": "bool256245529", "name": "verified", "presentable": false, "required": false, "system": true, "type": "bool" }, { "autogeneratePattern": "users[0-9]{6}", "hidden": false, "id": "text4166911607", "max": 150, "min": 3, "name": "username", "pattern": "^[\\w][\\w\\.\\-]*$", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "qkbp58ae", "maxSelect": 1, "name": "role", "presentable": false, "required": false, "system": false, "type": "select", "values": [ "user", "admin", "readonly" ] }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "indexes": [ "CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__username_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (username COLLATE NOCASE)", "CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''", "CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)" ], "system": false, "authRule": "verified=true", "manageRule": null }, { "id": "pbc_1864144027", "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "viewRule": null, "createRule": null, "updateRule": null, "deleteRule": null, "name": "containers", "type": "base", "fields": [ { "autogeneratePattern": "[a-f0-9]{6}", "hidden": false, "id": "text3208210256", "max": 12, "min": 6, "name": "id", "pattern": "^[a-f0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": false, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": false, "system": false, "type": "relation" }, { "autogeneratePattern": "", "hidden": false, "id": "text1579384326", "max": 0, "min": 0, "name": "name", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text2063623452", "max": 0, "min": 0, "name": "status", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number3470402323", "max": null, "min": null, "name": "health", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3128971310", "max": 100, "min": 0, "name": "cpu", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3933025333", "max": null, "min": 0, "name": "memory", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number4075427327", "max": null, "min": null, "name": "net", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "autogeneratePattern": "", "hidden": false, "id": "text3309110367", "max": 0, "min": 0, "name": "image", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text2308952269", "max": 0, "min": 0, "name": "ports", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number3332085495", "max": null, "min": null, "name": "updated", "onlyInt": true, "presentable": false, "required": true, "system": false, "type": "number" } ], "indexes": [ "CREATE INDEX ` + "`" + `idx_JxWirjdhyO` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `updated` + "`" + `)", "CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)" ], "system": false }, { "createRule": null, "deleteRule": null, "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", "hidden": false, "id": "text3208210256", "max": 10, "min": 6, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text1579384326", "max": 0, "min": 0, "name": "name", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": false, "system": false, "type": "relation" }, { "hidden": false, "id": "number2063623452", "max": null, "min": null, "name": "state", "onlyInt": true, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number1476559580", "max": null, "min": null, "name": "sub", "onlyInt": true, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3128971310", "max": null, "min": null, "name": "cpu", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number1052053287", "max": null, "min": null, "name": "cpuPeak", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3933025333", "max": null, "min": null, "name": "memory", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number1828797201", "max": null, "min": null, "name": "memPeak", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3332085495", "max": null, "min": null, "name": "updated", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" } ], "id": "pbc_3494996990", "indexes": [ "CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)", "CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)" ], "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "name": "systemd_services", "system": false, "type": "base", "updateRule": null, "viewRule": null }, { "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", "hidden": false, "id": "text3208210256", "max": 10, "min": 10, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "relation2375276105", "maxSelect": 1, "minSelect": 0, "name": "user", "presentable": false, "required": false, "system": false, "type": "relation" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": false, "system": false, "type": "relation" }, { "hidden": false, "id": "select2844932856", "maxSelect": 1, "name": "type", "presentable": false, "required": true, "system": false, "type": "select", "values": [ "one-time", "daily" ] }, { "hidden": false, "id": "date2675529103", "max": "", "min": "", "name": "start", "presentable": false, "required": true, "system": false, "type": "date" }, { "hidden": false, "id": "date16528305", "max": "", "min": "", "name": "end", "presentable": false, "required": true, "system": false, "type": "date" } ], "id": "pbc_451525641", "indexes": [ "CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)", "CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)" ], "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "name": "quiet_hours", "system": false, "type": "base", "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id" }, { "createRule": null, "deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", "hidden": false, "id": "text3208210256", "max": 10, "min": 10, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "autogeneratePattern": "", "hidden": false, "id": "text1579384326", "max": 0, "min": 0, "name": "name", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text3616895705", "max": 0, "min": 0, "name": "model", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text2744374011", "max": 0, "min": 0, "name": "state", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number3051925876", "max": null, "min": null, "name": "capacity", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number190023114", "max": null, "min": null, "name": "temp", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "autogeneratePattern": "", "hidden": false, "id": "text3589068740", "max": 0, "min": 0, "name": "firmware", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text3547646428", "max": 0, "min": 0, "name": "serial", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text2363381545", "max": 0, "min": 0, "name": "type", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number1234567890", "max": null, "min": null, "name": "hours", "onlyInt": true, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number0987654321", "max": null, "min": null, "name": "cycles", "onlyInt": true, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "json832282224", "maxSize": 0, "name": "attributes", "presentable": false, "required": false, "system": false, "type": "json" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "id": "pbc_2571630677", "indexes": [ "CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)" ], "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "name": "smart_devices", "system": false, "type": "base", "updateRule": null, "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" }, { "createRule": "", "deleteRule": "", "fields": [ { "autogeneratePattern": "[a-z0-9]{15}", "hidden": false, "id": "text3208210256", "max": 15, "min": 15, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "2hz5ncl8tizk5nx", "hidden": false, "id": "relation3377271179", "maxSelect": 1, "minSelect": 0, "name": "system", "presentable": false, "required": true, "system": false, "type": "relation" }, { "autogeneratePattern": "", "hidden": false, "id": "text3847340049", "max": 0, "min": 0, "name": "hostname", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number1789936913", "max": null, "min": null, "name": "os", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "autogeneratePattern": "", "hidden": false, "id": "text2818598173", "max": 0, "min": 0, "name": "os_name", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text1574083243", "max": 0, "min": 0, "name": "kernel", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text3128971310", "max": 0, "min": 0, "name": "cpu", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "autogeneratePattern": "", "hidden": false, "id": "text4161937994", "max": 0, "min": 0, "name": "arch", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "number4245036687", "max": null, "min": null, "name": "cores", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number1871592925", "max": null, "min": null, "name": "threads", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "number3933025333", "max": null, "min": null, "name": "memory", "onlyInt": false, "presentable": false, "required": false, "system": false, "type": "number" }, { "hidden": false, "id": "bool2200265312", "name": "podman", "presentable": false, "required": false, "system": false, "type": "bool" }, { "hidden": false, "id": "autodate3332085495", "name": "updated", "onCreate": true, "onUpdate": true, "presentable": false, "system": false, "type": "autodate" } ], "id": "pbc_3116237454", "indexes": [], "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id", "name": "system_details", "system": false, "type": "base", "updateRule": "", "viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id" }, { "createRule": null, "deleteRule": null, "fields": [ { "autogeneratePattern": "[a-z0-9]{10}", "hidden": false, "id": "text3208210256", "max": 10, "min": 10, "name": "id", "pattern": "^[a-z0-9]+$", "presentable": false, "primaryKey": true, "required": true, "system": true, "type": "text" }, { "cascadeDelete": true, "collectionId": "_pb_users_auth_", "hidden": false, "id": "relation2375276105", "maxSelect": 1, "minSelect": 0, "name": "user", "presentable": false, "required": true, "system": false, "type": "relation" }, { "autogeneratePattern": "", "hidden": false, "id": "text1597481275", "max": 0, "min": 0, "name": "token", "pattern": "", "presentable": false, "primaryKey": false, "required": false, "system": false, "type": "text" }, { "hidden": false, "id": "autodate2990389176", "name": "created", "onCreate": true, "onUpdate": false, "presentable": false, "system": false, "type": "autodate" } ], "id": "pbc_3383022248", "indexes": [ "CREATE INDEX ` + "`" + `idx_iaD9Y2Lgbl` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `token` + "`" + `)", "CREATE UNIQUE INDEX ` + "`" + `idx_wdR0A4PbRG` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `user` + "`" + `)" ], "listRule": null, "name": "universal_tokens", "system": false, "type": "base", "updateRule": null, "viewRule": null } ]` err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false) if err != nil { return err } return nil }, func(app core.App) error { return nil }) } ================================================ FILE: internal/migrations/initial-settings.go ================================================ package migrations import ( "os" "github.com/pocketbase/pocketbase/core" m "github.com/pocketbase/pocketbase/migrations" ) const ( TempAdminEmail = "_@b.b" ) func init() { m.Register(func(app core.App) error { // initial settings settings := app.Settings() settings.Meta.AppName = "Beszel" settings.Meta.HideControls = true settings.Logs.MinLevel = 4 if err := app.Save(settings); err != nil { return err } // create superuser superuserCollection, _ := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) superUser := core.NewRecord(superuserCollection) // set email email, _ := GetEnv("USER_EMAIL") password, _ := GetEnv("USER_PASSWORD") didProvideUserDetails := email != "" && password != "" // set superuser email if email == "" { email = TempAdminEmail } superUser.SetEmail(email) // set superuser password if password != "" { superUser.SetPassword(password) } else { superUser.SetRandomPassword() } // if user details are provided, we create a regular user as well if didProvideUserDetails { usersCollection, _ := app.FindCollectionByNameOrId("users") user := core.NewRecord(usersCollection) user.SetEmail(email) user.SetPassword(password) user.SetVerified(true) err := app.Save(user) if err != nil { return err } } return app.Save(superUser) }, nil) } // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key. func GetEnv(key string) (value string, exists bool) { if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists { return value, exists } // Fallback to the old unprefixed key return os.LookupEnv(key) } ================================================ FILE: internal/records/records.go ================================================ // Package records handles creating longer records and deleting old records. package records import ( "encoding/json" "fmt" "log" "math" "strings" "time" "github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/system" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" ) type RecordManager struct { app core.App } type LongerRecordData struct { shorterType string longerType string longerTimeDuration time.Duration minShorterRecords int } type RecordIds []struct { Id string `db:"id"` } func NewRecordManager(app core.App) *RecordManager { return &RecordManager{app} } type StatsRecord struct { Stats []byte `db:"stats"` } // global variables for reusing allocations var ( statsRecord StatsRecord containerStats []container.Stats sumStats system.Stats tempStats system.Stats queryParams = make(dbx.Params, 1) containerSums = make(map[string]*container.Stats) ) // Create longer records by averaging shorter records func (rm *RecordManager) CreateLongerRecords() { // start := time.Now() longerRecordData := []LongerRecordData{ { shorterType: "1m", // change to 9 from 10 to allow edge case timing or short pauses minShorterRecords: 9, longerType: "10m", longerTimeDuration: -10 * time.Minute, }, { shorterType: "10m", minShorterRecords: 2, longerType: "20m", longerTimeDuration: -20 * time.Minute, }, { shorterType: "20m", minShorterRecords: 6, longerType: "120m", longerTimeDuration: -120 * time.Minute, }, { shorterType: "120m", minShorterRecords: 4, longerType: "480m", longerTimeDuration: -480 * time.Minute, }, } // wrap the operations in a transaction rm.app.RunInTransaction(func(txApp core.App) error { var err error collections := [2]*core.Collection{} collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats") if err != nil { return err } collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats") if err != nil { return err } var systems RecordIds db := txApp.DB() db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems) // loop through all active systems, time periods, and collections for _, system := range systems { // log.Println("processing system", system.GetString("name")) for i := range longerRecordData { recordData := longerRecordData[i] // log.Println("processing longer record type", recordData.longerType) // add one minute padding for longer records because they are created slightly later than the job start time longerRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration + time.Minute) // shorter records are created independently of longer records, so we shouldn't need to add padding shorterRecordPeriod := time.Now().UTC().Add(recordData.longerTimeDuration) // loop through both collections for _, collection := range collections { // check creation time of last longer record if not 10m, since 10m is created every run if recordData.longerType != "10m" { count, err := txApp.CountRecords( collection.Id, dbx.NewExp( "system = {:system} AND type = {:type} AND created > {:created}", dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod}, ), ) // continue if longer record exists if err != nil || count > 0 { continue } } // get shorter records from the past x minutes var recordIds RecordIds err := txApp.DB(). Select("id"). From(collection.Name). AndWhere(dbx.NewExp( "system={:system} AND type={:type} AND created > {:created}", dbx.Params{ "type": recordData.shorterType, "system": system.Id, "created": shorterRecordPeriod, }, )). All(&recordIds) // continue if not enough shorter records if err != nil || len(recordIds) < recordData.minShorterRecords { continue } // average the shorter records and create longer record longerRecord := core.NewRecord(collection) longerRecord.Set("system", system.Id) longerRecord.Set("type", recordData.longerType) switch collection.Name { case "system_stats": longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds)) case "container_stats": longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds)) } if err := txApp.SaveNoValidate(longerRecord); err != nil { log.Println("failed to save longer record", "err", err) } } } } return nil }) statsRecord.Stats = statsRecord.Stats[:0] // log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds()) } // Calculate the average stats of a list of system_stats records without reflect func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats { // Clear/reset global structs for reuse sumStats = system.Stats{} tempStats = system.Stats{} sum := &sumStats stats := &tempStats // necessary because uint8 is not big enough for the sum batterySum := 0 // accumulate per-core usage across records var cpuCoresSums []uint64 // accumulate cpu breakdown [user, system, iowait, steal, idle] var cpuBreakdownSums []float64 count := float64(len(records)) tempCount := float64(0) // Accumulate totals for _, record := range records { id := record.Id // clear global statsRecord for reuse statsRecord.Stats = statsRecord.Stats[:0] // reset tempStats each iteration to avoid omitzero fields retaining stale values *stats = system.Stats{} queryParams["id"] = id db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord) if err := json.Unmarshal(statsRecord.Stats, stats); err != nil { continue } sum.Cpu += stats.Cpu // accumulate cpu time breakdowns if present if stats.CpuBreakdown != nil { if len(cpuBreakdownSums) < len(stats.CpuBreakdown) { cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...) } for i, v := range stats.CpuBreakdown { cpuBreakdownSums[i] += v } } sum.Mem += stats.Mem sum.MemUsed += stats.MemUsed sum.MemPct += stats.MemPct sum.MemBuffCache += stats.MemBuffCache sum.MemZfsArc += stats.MemZfsArc sum.Swap += stats.Swap sum.SwapUsed += stats.SwapUsed sum.DiskTotal += stats.DiskTotal sum.DiskUsed += stats.DiskUsed sum.DiskPct += stats.DiskPct sum.DiskReadPs += stats.DiskReadPs sum.DiskWritePs += stats.DiskWritePs sum.NetworkSent += stats.NetworkSent sum.NetworkRecv += stats.NetworkRecv sum.LoadAvg[0] += stats.LoadAvg[0] sum.LoadAvg[1] += stats.LoadAvg[1] sum.LoadAvg[2] += stats.LoadAvg[2] sum.Bandwidth[0] += stats.Bandwidth[0] sum.Bandwidth[1] += stats.Bandwidth[1] sum.DiskIO[0] += stats.DiskIO[0] sum.DiskIO[1] += stats.DiskIO[1] batterySum += int(stats.Battery[0]) sum.Battery[1] = stats.Battery[1] // accumulate per-core usage if present if stats.CpuCoresUsage != nil { if len(cpuCoresSums) < len(stats.CpuCoresUsage) { // extend slices to accommodate core count cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...) } for i, v := range stats.CpuCoresUsage { cpuCoresSums[i] += uint64(v) } } // Set peak values sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu) sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed) sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent) sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv) sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs) sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs) sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0]) sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1]) sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0]) sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1]) // Accumulate network interfaces if sum.NetworkInterfaces == nil { sum.NetworkInterfaces = make(map[string][4]uint64, len(stats.NetworkInterfaces)) } for key, value := range stats.NetworkInterfaces { sum.NetworkInterfaces[key] = [4]uint64{ sum.NetworkInterfaces[key][0] + value[0], sum.NetworkInterfaces[key][1] + value[1], max(sum.NetworkInterfaces[key][2], value[2]), max(sum.NetworkInterfaces[key][3], value[3]), } } // Accumulate temperatures if stats.Temperatures != nil { if sum.Temperatures == nil { sum.Temperatures = make(map[string]float64, len(stats.Temperatures)) } tempCount++ for key, value := range stats.Temperatures { sum.Temperatures[key] += value } } // Accumulate extra filesystem stats if stats.ExtraFs != nil { if sum.ExtraFs == nil { sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs)) } for key, value := range stats.ExtraFs { if _, ok := sum.ExtraFs[key]; !ok { sum.ExtraFs[key] = &system.FsStats{} } fs := sum.ExtraFs[key] fs.DiskTotal += value.DiskTotal fs.DiskUsed += value.DiskUsed fs.DiskWritePs += value.DiskWritePs fs.DiskReadPs += value.DiskReadPs fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs) fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs) fs.DiskReadBytes += value.DiskReadBytes fs.DiskWriteBytes += value.DiskWriteBytes fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes) fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes) } } // Accumulate GPU data if stats.GPUData != nil { if sum.GPUData == nil { sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData)) } for id, value := range stats.GPUData { gpu, ok := sum.GPUData[id] if !ok { gpu = system.GPUData{Name: value.Name} } gpu.Temperature += value.Temperature gpu.MemoryUsed += value.MemoryUsed gpu.MemoryTotal += value.MemoryTotal gpu.Usage += value.Usage gpu.Power += value.Power gpu.Count += value.Count if value.Engines != nil { if gpu.Engines == nil { gpu.Engines = make(map[string]float64, len(value.Engines)) } for engineKey, engineValue := range value.Engines { gpu.Engines[engineKey] += engineValue } } sum.GPUData[id] = gpu } } } // Compute averages in place if count > 0 { sum.Cpu = twoDecimals(sum.Cpu / count) sum.Mem = twoDecimals(sum.Mem / count) sum.MemUsed = twoDecimals(sum.MemUsed / count) sum.MemPct = twoDecimals(sum.MemPct / count) sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count) sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count) sum.Swap = twoDecimals(sum.Swap / count) sum.SwapUsed = twoDecimals(sum.SwapUsed / count) sum.DiskTotal = twoDecimals(sum.DiskTotal / count) sum.DiskUsed = twoDecimals(sum.DiskUsed / count) sum.DiskPct = twoDecimals(sum.DiskPct / count) sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count) sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count) sum.DiskIO[0] = sum.DiskIO[0] / uint64(count) sum.DiskIO[1] = sum.DiskIO[1] / uint64(count) sum.NetworkSent = twoDecimals(sum.NetworkSent / count) sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count) sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count) sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count) sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count) sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count) sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count) sum.Battery[0] = uint8(batterySum / int(count)) // Average network interfaces if sum.NetworkInterfaces != nil { for key := range sum.NetworkInterfaces { sum.NetworkInterfaces[key] = [4]uint64{ sum.NetworkInterfaces[key][0] / uint64(count), sum.NetworkInterfaces[key][1] / uint64(count), sum.NetworkInterfaces[key][2], sum.NetworkInterfaces[key][3], } } } // Average temperatures if sum.Temperatures != nil && tempCount > 0 { for key := range sum.Temperatures { sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount) } } // Average extra filesystem stats if sum.ExtraFs != nil { for key := range sum.ExtraFs { fs := sum.ExtraFs[key] fs.DiskTotal = twoDecimals(fs.DiskTotal / count) fs.DiskUsed = twoDecimals(fs.DiskUsed / count) fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count) fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count) fs.DiskReadBytes = fs.DiskReadBytes / uint64(count) fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count) } } // Average GPU data if sum.GPUData != nil { for id := range sum.GPUData { gpu := sum.GPUData[id] gpu.Temperature = twoDecimals(gpu.Temperature / count) gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count) gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count) gpu.Usage = twoDecimals(gpu.Usage / count) gpu.Power = twoDecimals(gpu.Power / count) gpu.Count = twoDecimals(gpu.Count / count) if gpu.Engines != nil { for engineKey := range gpu.Engines { gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count) } } sum.GPUData[id] = gpu } } // Average per-core usage if len(cpuCoresSums) > 0 { avg := make(system.Uint8Slice, len(cpuCoresSums)) for i := range cpuCoresSums { v := math.Round(float64(cpuCoresSums[i]) / count) avg[i] = uint8(v) } sum.CpuCoresUsage = avg } // Average CPU breakdown if len(cpuBreakdownSums) > 0 { avg := make([]float64, len(cpuBreakdownSums)) for i := range cpuBreakdownSums { avg[i] = twoDecimals(cpuBreakdownSums[i] / count) } sum.CpuBreakdown = avg } } return sum } // Calculate the average stats of a list of container_stats records func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats { // Clear global map for reuse for k := range containerSums { delete(containerSums, k) } sums := containerSums count := float64(len(records)) for i := range records { id := records[i].Id // clear global statsRecord for reuse statsRecord.Stats = statsRecord.Stats[:0] // must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array // which causes omitzero fields to inherit stale values from previous iterations containerStats = nil queryParams["id"] = id db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord) if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil { return []container.Stats{} } for i := range containerStats { stat := containerStats[i] if _, ok := sums[stat.Name]; !ok { sums[stat.Name] = &container.Stats{Name: stat.Name} } sums[stat.Name].Cpu += stat.Cpu sums[stat.Name].Mem += stat.Mem sentBytes := stat.Bandwidth[0] recvBytes := stat.Bandwidth[1] if sentBytes == 0 && recvBytes == 0 && (stat.NetworkSent != 0 || stat.NetworkRecv != 0) { sentBytes = uint64(stat.NetworkSent * 1024 * 1024) recvBytes = uint64(stat.NetworkRecv * 1024 * 1024) } sums[stat.Name].Bandwidth[0] += sentBytes sums[stat.Name].Bandwidth[1] += recvBytes } } result := make([]container.Stats, 0, len(sums)) for _, value := range sums { result = append(result, container.Stats{ Name: value.Name, Cpu: twoDecimals(value.Cpu / count), Mem: twoDecimals(value.Mem / count), Bandwidth: [2]uint64{uint64(float64(value.Bandwidth[0]) / count), uint64(float64(value.Bandwidth[1]) / count)}, }) } return result } // Delete old records func (rm *RecordManager) DeleteOldRecords() { rm.app.RunInTransaction(func(txApp core.App) error { err := deleteOldSystemStats(txApp) if err != nil { return err } err = deleteOldContainerRecords(txApp) if err != nil { return err } err = deleteOldSystemdServiceRecords(txApp) if err != nil { return err } err = deleteOldAlertsHistory(txApp, 200, 250) if err != nil { return err } err = deleteOldQuietHours(txApp) if err != nil { return err } return nil }) } // Delete old alerts history records func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error { db := app.DB() var users []struct { Id string `db:"user"` } err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users) if err != nil { return err } for _, user := range users { _, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute() if err != nil { return err } } return nil } // Deletes system_stats records older than what is displayed in the UI func deleteOldSystemStats(app core.App) error { // Collections to process collections := [2]string{"system_stats", "container_stats"} // Record types and their retention periods type RecordDeletionData struct { recordType string retention time.Duration } recordData := []RecordDeletionData{ {recordType: "1m", retention: time.Hour}, // 1 hour {recordType: "10m", retention: 12 * time.Hour}, // 12 hours {recordType: "20m", retention: 24 * time.Hour}, // 1 day {recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days {recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days } now := time.Now().UTC() for _, collection := range collections { // Build the WHERE clause var conditionParts []string var params dbx.Params = make(map[string]any) for i := range recordData { rd := recordData[i] // Create parameterized condition for this record type dateParam := fmt.Sprintf("date%d", i) conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam)) params[dateParam] = now.Add(-rd.retention) } // Combine conditions with OR conditionStr := strings.Join(conditionParts, " OR ") // Construct and execute the full raw query rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr) if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil { return fmt.Errorf("failed to delete from %s: %v", collection, err) } } return nil } // Deletes systemd service records that haven't been updated in the last 20 minutes func deleteOldSystemdServiceRecords(app core.App) error { now := time.Now().UTC() twentyMinutesAgo := now.Add(-20 * time.Minute) // Delete systemd service records where updated < twentyMinutesAgo _, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute() if err != nil { return fmt.Errorf("failed to delete old systemd service records: %v", err) } return nil } // Deletes container records that haven't been updated in the last 10 minutes func deleteOldContainerRecords(app core.App) error { now := time.Now().UTC() tenMinutesAgo := now.Add(-10 * time.Minute) // Delete container records where updated < tenMinutesAgo _, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute() if err != nil { return fmt.Errorf("failed to delete old container records: %v", err) } return nil } // Deletes old quiet hours records where end date has passed func deleteOldQuietHours(app core.App) error { now := time.Now().UTC() _, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute() if err != nil { return err } return nil } /* Round float to two decimals */ func twoDecimals(value float64) float64 { return math.Round(value*100) / 100 } ================================================ FILE: internal/records/records_test.go ================================================ //go:build testing package records_test import ( "fmt" "testing" "time" "github.com/henrygd/beszel/internal/records" "github.com/henrygd/beszel/internal/tests" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestDeleteOldRecords tests the main DeleteOldRecords function func TestDeleteOldRecords(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() rm := records.NewRecordManager(hub) // Create test user for alerts history user, err := tests.CreateUser(hub, "test@example.com", "testtesttest") require.NoError(t, err) // Create test system system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "up", "users": []string{user.Id}, }) require.NoError(t, err) now := time.Now() // Create old system_stats records that should be deleted var record *core.Record record, err = tests.CreateRecord(hub, "system_stats", map[string]any{ "system": system.Id, "type": "1m", "stats": `{"cpu": 50.0, "mem": 1024}`, }) require.NoError(t, err) // created is autodate field, so we need to set it manually record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) require.NotNil(t, record) require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1) require.Equal(t, record.Get("system"), system.Id) require.Equal(t, record.Get("type"), "1m") // Create recent system_stats record that should be kept _, err = tests.CreateRecord(hub, "system_stats", map[string]any{ "system": system.Id, "type": "1m", "stats": `{"cpu": 30.0, "mem": 512}`, "created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept }) require.NoError(t, err) // Create many alerts history records to trigger deletion for i := range 260 { // More than countBeforeDeletion (250) _, err = tests.CreateRecord(hub, "alerts_history", map[string]any{ "user": user.Id, "name": "CPU", "value": i + 1, "system": system.Id, "created": now.Add(-time.Duration(i) * time.Minute), }) require.NoError(t, err) } // Count records before deletion systemStatsCountBefore, err := hub.CountRecords("system_stats") require.NoError(t, err) alertsCountBefore, err := hub.CountRecords("alerts_history") require.NoError(t, err) // Run deletion rm.DeleteOldRecords() // Count records after deletion systemStatsCountAfter, err := hub.CountRecords("system_stats") require.NoError(t, err) alertsCountAfter, err := hub.CountRecords("alerts_history") require.NoError(t, err) // Verify old system stats were deleted assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted") // Verify alerts history was trimmed assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted") assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)") } // TestDeleteOldSystemStats tests the deleteOldSystemStats function func TestDeleteOldSystemStats(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() // Create test system user, err := tests.CreateUser(hub, "test@example.com", "testtesttest") require.NoError(t, err) system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "up", "users": []string{user.Id}, }) require.NoError(t, err) now := time.Now().UTC() // Test data for different record types and their retention periods testCases := []struct { recordType string retention time.Duration shouldBeKept bool ageFromNow time.Duration description string }{ {"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"}, {"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"}, {"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"}, {"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"}, {"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"}, {"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"}, {"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"}, {"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"}, {"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"}, {"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"}, } // Create test records for both system_stats and container_stats collections := []string{"system_stats", "container_stats"} recordIds := make(map[string][]string) for _, collection := range collections { recordIds[collection] = make([]string, 0) for i, tc := range testCases { recordTime := now.Add(-tc.ageFromNow) var stats string if collection == "system_stats" { stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100) } else { stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50) } record, err := tests.CreateRecord(hub, collection, map[string]any{ "system": system.Id, "type": tc.recordType, "stats": stats, }) require.NoError(t, err) record.SetRaw("created", recordTime.Format(types.DefaultDateLayout)) err = hub.SaveNoValidate(record) require.NoError(t, err) recordIds[collection] = append(recordIds[collection], record.Id) } } // Run deletion err = records.DeleteOldSystemStats(hub) require.NoError(t, err) // Verify results for _, collection := range collections { for i, tc := range testCases { recordId := recordIds[collection][i] // Try to find the record _, err := hub.FindRecordById(collection, recordId) if tc.shouldBeKept { assert.NoError(t, err, "Record should exist: %s", tc.description) } else { assert.Error(t, err, "Record should be deleted: %s", tc.description) } } } } // TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function func TestDeleteOldAlertsHistory(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() // Create test users user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest") require.NoError(t, err) user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest") require.NoError(t, err) system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "up", "users": []string{user1.Id, user2.Id}, }) require.NoError(t, err) now := time.Now().UTC() testCases := []struct { name string user *core.Record alertCount int countToKeep int countBeforeDeletion int expectedAfterDeletion int description string }{ { name: "User with few alerts (below threshold)", user: user1, alertCount: 100, countToKeep: 50, countBeforeDeletion: 150, expectedAfterDeletion: 100, // No deletion because below threshold description: "User with alerts below countBeforeDeletion should not have any deleted", }, { name: "User with many alerts (above threshold)", user: user2, alertCount: 300, countToKeep: 100, countBeforeDeletion: 200, expectedAfterDeletion: 100, // Should be trimmed to countToKeep description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create alerts for this user for i := 0; i < tc.alertCount; i++ { _, err := tests.CreateRecord(hub, "alerts_history", map[string]any{ "user": tc.user.Id, "name": "CPU", "value": i + 1, "system": system.Id, "created": now.Add(-time.Duration(i) * time.Minute), }) require.NoError(t, err) } // Count before deletion countBefore, err := hub.CountRecords("alerts_history", dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id})) require.NoError(t, err) assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match") // Run deletion err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion) require.NoError(t, err) // Count after deletion countAfter, err := hub.CountRecords("alerts_history", dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id})) require.NoError(t, err) assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description) // If deletion occurred, verify the most recent records were kept if tc.expectedAfterDeletion < tc.alertCount { records, err := hub.FindRecordsByFilter("alerts_history", "user = {:user}", "-created", // Order by created DESC tc.countToKeep, 0, map[string]any{"user": tc.user.Id}) require.NoError(t, err) assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records") // Verify records are in descending order by created time for i := 1; i < len(records); i++ { prev := records[i-1].GetDateTime("created").Time() curr := records[i].GetDateTime("created").Time() assert.True(t, prev.After(curr) || prev.Equal(curr), "Records should be ordered by created time (newest first)") } } }) } } // TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() t.Run("No users with excessive alerts", func(t *testing.T) { // Create user with few alerts user, err := tests.CreateUser(hub, "few@example.com", "testtesttest") require.NoError(t, err) system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "up", "users": []string{user.Id}, }) // Create only 5 alerts (well below threshold) for i := range 5 { _, err := tests.CreateRecord(hub, "alerts_history", map[string]any{ "user": user.Id, "name": "CPU", "value": i + 1, "system": system.Id, }) require.NoError(t, err) } // Should not error and should not delete anything err = records.DeleteOldAlertsHistory(hub, 10, 20) require.NoError(t, err) count, err := hub.CountRecords("alerts_history") require.NoError(t, err) assert.Equal(t, int64(5), count, "All alerts should remain") }) t.Run("Empty alerts_history table", func(t *testing.T) { // Clear any existing alerts _, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute() require.NoError(t, err) // Should not error with empty table err = records.DeleteOldAlertsHistory(hub, 10, 20) require.NoError(t, err) }) } // TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords func TestDeleteOldSystemdServiceRecords(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() rm := records.NewRecordManager(hub) // Create test user and system user, err := tests.CreateUser(hub, "test@example.com", "testtesttest") require.NoError(t, err) system, err := tests.CreateRecord(hub, "systems", map[string]any{ "name": "test-system", "host": "localhost", "port": "45876", "status": "up", "users": []string{user.Id}, }) require.NoError(t, err) now := time.Now().UTC() // Create old systemd service records that should be deleted (older than 20 minutes) oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{ "system": system.Id, "name": "nginx.service", "state": 0, // Active "sub": 1, // Running "cpu": 5.0, "cpuPeak": 10.0, "memory": 1024000, "memPeak": 2048000, }) require.NoError(t, err) // Set updated time to 25 minutes ago (should be deleted) oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli()) err = hub.SaveNoValidate(oldRecord) require.NoError(t, err) // Create recent systemd service record that should be kept (within 20 minutes) recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{ "system": system.Id, "name": "apache.service", "state": 1, // Inactive "sub": 0, // Dead "cpu": 2.0, "cpuPeak": 3.0, "memory": 512000, "memPeak": 1024000, }) require.NoError(t, err) // Set updated time to 10 minutes ago (should be kept) recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli()) err = hub.SaveNoValidate(recentRecord) require.NoError(t, err) // Count records before deletion countBefore, err := hub.CountRecords("systemd_services") require.NoError(t, err) assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially") // Run deletion via RecordManager rm.DeleteOldRecords() // Count records after deletion countAfter, err := hub.CountRecords("systemd_services") require.NoError(t, err) assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion") // Verify the correct record was kept remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil) require.NoError(t, err) assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining") assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept") } // TestRecordManagerCreation tests RecordManager creation func TestRecordManagerCreation(t *testing.T) { hub, err := tests.NewTestHub(t.TempDir()) require.NoError(t, err) defer hub.Cleanup() rm := records.NewRecordManager(hub) assert.NotNil(t, rm, "RecordManager should not be nil") } // TestTwoDecimals tests the twoDecimals helper function func TestTwoDecimals(t *testing.T) { testCases := []struct { input float64 expected float64 }{ {1.234567, 1.23}, {1.235, 1.24}, // Should round up {1.0, 1.0}, {0.0, 0.0}, {-1.234567, -1.23}, {-1.235, -1.23}, // Negative rounding } for _, tc := range testCases { result := records.TwoDecimals(tc.input) assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected) } } ================================================ FILE: internal/records/records_test_helpers.go ================================================ //go:build testing package records import ( "github.com/pocketbase/pocketbase/core" ) // DeleteOldSystemStats exposes deleteOldSystemStats for testing func DeleteOldSystemStats(app core.App) error { return deleteOldSystemStats(app) } // DeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing func DeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error { return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion) } // TwoDecimals exposes twoDecimals for testing func TwoDecimals(value float64) float64 { return twoDecimals(value) } ================================================ FILE: internal/site/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: internal/site/.prettierrc ================================================ { "trailingComma": "es5", "useTabs": true, "tabWidth": 2, "semi": false, "singleQuote": false, "printWidth": 120 } ================================================ FILE: internal/site/biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true, "defaultBranch": "main" }, "formatter": { "enabled": true, "indentStyle": "tab", "lineWidth": 120, "formatWithErrors": true }, "assist": { "actions": { "source": { "organizeImports": "off" } } }, "linter": { "enabled": true, "rules": { "recommended": true, "a11y": { "useButtonType": "off" }, "complexity": { "noUselessStringConcat": "error", "noUselessUndefinedInitialization": "error", "noVoid": "error", "useDateNow": "error" }, "correctness": { "noConstantMathMinMaxClamp": "error", "noUndeclaredVariables": "error", "noUnusedImports": "error", "noUnusedFunctionParameters": "error", "noUnusedPrivateClassMembers": "error", "useExhaustiveDependencies": { "level": "off" }, "useUniqueElementIds": "off", "noUnusedVariables": "error" }, "security": { "noDangerouslySetInnerHtml": "warn" }, "style": { "noParameterProperties": "error", "noYodaExpression": "error", "useConsistentBuiltinInstantiation": "error", "useFragmentSyntax": "error", "useShorthandAssign": "error", "useArrayLiterals": "error" }, "suspicious": { "useAwait": "error", "noEvolvingTypes": "error", "noArrayIndexKey": "off" } } }, "javascript": { "formatter": { "quoteStyle": "double", "trailingCommas": "es5", "semicolons": "asNeeded" } }, "overrides": [ { "includes": ["**/*.jsx", "**/*.tsx"], "linter": { "rules": { "style": { "noParameterAssign": "error" } } } }, { "includes": ["**/*.ts", "**/*.tsx"], "linter": { "rules": { "correctness": { "noUnusedVariables": "off" } } } } ] } ================================================ FILE: internal/site/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "gray", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } } ================================================ FILE: internal/site/embed.go ================================================ // Package site handles the Beszel frontend embedding. package site import ( "embed" "io/fs" ) //go:embed all:dist var distDir embed.FS // DistDirFS contains the embedded dist directory files (without the "dist" prefix) var DistDirFS, _ = fs.Sub(distDir, "dist") ================================================ FILE: internal/site/index.html ================================================ Beszel
================================================ FILE: internal/site/lingui.config.ts ================================================ import { defineConfig } from "@lingui/cli" export default defineConfig({ locales: [ "en", "ar", "bg", "cs", "da", "de", "es", "fa", "fr", "he", "hr", "hu", "id", "it", "ja", "ko", "nl", "no", "pl", "pt", "tr", "ru", "sl", "sr", "sv", "uk", "vi", "zh", "zh-CN", "zh-HK", ], sourceLocale: "en", compileNamespace: "ts", formatOptions: { lineNumbers: false, }, catalogs: [ { path: "/src/locales/{locale}/{locale}", include: ["src"], }, ], }) ================================================ FILE: internal/site/package.json ================================================ { "name": "beszel", "private": true, "version": "0.18.4", "type": "module", "scripts": { "dev": "vite --host", "build": "lingui extract --overwrite && lingui compile && vite build", "preview": "vite preview", "sync": "lingui extract --overwrite && lingui compile", "sync_no_compile": "lingui extract --overwrite --clean", "sync_and_purge": "lingui extract --overwrite --clean && lingui compile", "format": "biome format --write .", "lint": "biome lint .", "check": "biome check .", "check:fix": "biome check --fix ." }, "dependencies": { "@henrygd/queue": "^1.0.7", "@henrygd/semaphore": "^0.0.2", "@lingui/detect-locale": "^5.4.1", "@lingui/macro": "^5.4.1", "@lingui/react": "^5.4.1", "@nanostores/react": "^0.7.3", "@nanostores/router": "^0.11.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-time": "^3.1.0", "input-otp": "^1.4.2", "lucide-react": "^0.452.0", "nanostores": "^0.11.4", "pocketbase": "^0.26.2", "react": "^19.1.2", "react-dom": "^19.1.2", "recharts": "^2.15.4", "shiki": "^3.13.0", "tailwind-merge": "^3.3.1", "valibot": "^0.42.1" }, "devDependencies": { "@biomejs/biome": "2.2.4", "@lingui/cli": "^5.4.1", "@lingui/swc-plugin": "^5.6.1", "@lingui/vite-plugin": "^5.4.1", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/vite": "^4.1.12", "@types/bun": "^1.2.20", "@types/react": "^19.1.11", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react-swc": "^4.0.1", "tailwindcss": "^4.1.12", "tw-animate-css": "^1.3.7", "typescript": "^5.9.2", "vite": "^7.1.3" }, "overrides": { "@nanostores/router": { "nanostores": "^0.11.3" } }, "optionalDependencies": { "@esbuild/linux-arm64": "^0.21.5" } } ================================================ FILE: internal/site/public/static/manifest.json ================================================ { "name": "Beszel", "icons": [ { "src": "icon.png", "sizes": "512x512", "type": "image/png" } ], "start_url": "../", "display": "standalone", "background_color": "#202225", "theme_color": "#202225" } ================================================ FILE: internal/site/src/components/active-alerts.tsx ================================================ import { alertInfo } from "@/lib/alerts" import { $alerts, $allSystemsById } from "@/lib/stores" import type { AlertRecord } from "@/types" import { Plural, Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { useMemo } from "react" import { $router, Link } from "./router" import { Alert, AlertTitle, AlertDescription } from "./ui/alert" import { Card, CardHeader, CardTitle, CardContent } from "./ui/card" export const ActiveAlerts = () => { const alerts = useStore($alerts) const systems = useStore($allSystemsById) const { activeAlerts, alertsKey } = useMemo(() => { const activeAlerts: AlertRecord[] = [] // key to prevent re-rendering if alerts change but active alerts didn't const alertsKey: string[] = [] for (const systemId of Object.keys(alerts)) { for (const alert of alerts[systemId].values()) { if (alert.triggered && alert.name in alertInfo) { activeAlerts.push(alert) alertsKey.push(`${alert.system}${alert.value}${alert.min}`) } } } return { activeAlerts, alertsKey } }, [alerts]) // biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive return useMemo(() => { if (activeAlerts.length === 0) { return null } return (
Active Alerts
{activeAlerts.length > 0 && (
{activeAlerts.map((alert) => { const info = alertInfo[alert.name as keyof typeof alertInfo] return ( {systems[alert.system]?.name} {info.name()} {alert.name === "Status" ? ( Connection is down ) : info.invert ? ( Below {alert.value} {info.unit} in last ) : ( Exceeds {alert.value} {info.unit} in last )} ) })}
)}
) }, [alertsKey.join("")]) } ================================================ FILE: internal/site/src/components/add-system.tsx ================================================ import { msg, t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react" import { memo, useEffect, useRef, useState } from "react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { isReadOnlyUser, pb } from "@/lib/api" import { SystemStatus } from "@/lib/enums" import { $publicKey } from "@/lib/stores" import { cn, generateToken, tokenMap, useBrowserStorage } from "@/lib/utils" import type { SystemRecord } from "@/types" import { copyDockerCompose, copyDockerRun, copyLinuxCommand, copyWindowsCommand, type DropdownItem, InstallDropdown, } from "./install-dropdowns" import { $router, basePath, Link, navigate } from "./router" import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu" import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons" import { InputCopy } from "./ui/input-copy" export function AddSystemButton({ className }: { className?: string }) { if (isReadOnlyUser()) { return null } const [open, setOpen] = useState(false) const opened = useRef(false) if (open) { opened.current = true } return ( {opened.current && } ) } /** * Token to be used for the next system. * Prevents token changing if user copies config, then closes dialog and opens again. */ let nextSystemToken: string | null = null /** * SystemDialog component for adding or editing a system. * @param {Object} props - The component props. * @param {function} props.setOpen - Function to set the open state of the dialog. * @param {SystemRecord} [props.system] - Optional system record for editing an existing system. */ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => { const publicKey = useStore($publicKey) const port = useRef(null) const [hostValue, setHostValue] = useState(system?.host ?? "") const isUnixSocket = hostValue.startsWith("/") const [tab, setTab] = useBrowserStorage("as-tab", "docker") const [token, setToken] = useState(system?.token ?? "") useEffect(() => { ;(async () => { // if no system, generate a new token if (!system) { nextSystemToken ||= generateToken() return setToken(nextSystemToken) } // if system exists,get the token from the fingerprint record if (tokenMap.has(system.id)) { return setToken(tokenMap.get(system.id)!) } const { token } = await pb.collection("fingerprints").getFirstListItem(`system = "${system.id}"`, { fields: "token", }) tokenMap.set(system.id, token) setToken(token) })() }, [system?.id, nextSystemToken]) async function handleSubmit(e: SubmitEvent) { e.preventDefault() const formData = new FormData(e.target as HTMLFormElement) const data = Object.fromEntries(formData) as Record data.users = pb.authStore.record!.id try { setOpen(false) if (system) { await pb.collection("systems").update(system.id, { ...data, status: SystemStatus.Pending }) } else { const createdSystem = await pb.collection("systems").create(data) await pb.collection("fingerprints").create({ system: createdSystem.id, token, }) // Reset the current token after successful system // creation so next system gets a new token nextSystemToken = null } navigate(basePath) } catch (e) { console.error(e) } } const systemTranslation = t`System` return ( { setHostValue(system?.host ?? "") }} > {system ? ( Edit {{ foo: systemTranslation }} ) : ( Add {{ foo: systemTranslation }} )} Docker Binary {/* Docker (set tab index to prevent auto focusing content in edit system dialog) */} Copy the docker-compose.yml content for the agent below, or register agents automatically with a{" "} setOpen(false)} href={getPagePath($router, "settings", { name: "tokens" })} className="link" > universal token . {/* Binary */} Copy the installation command for the agent below, or register agents automatically with a{" "} setOpen(false)} href={getPagePath($router, "settings", { name: "tokens" })} className="link" > universal token .
{ setHostValue(e.target.value) }} />
{/* Docker */} copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token) } icon={} dropdownItems={[ { text: t({ message: "Copy docker run", context: "Button to copy docker run command" }), onClick: async () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token), icons: [DockerIcon], }, ]} /> {/* Binary */} } onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)} dropdownItems={[ { text: t({ message: "Homebrew command", context: "Button to copy install command" }), onClick: async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token, true), icons: [AppleIcon, TuxIcon], }, { text: t({ message: "Windows command", context: "Button to copy install command" }), onClick: async () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token), icons: [WindowsIcon], }, { text: t({ message: "FreeBSD command", context: "Button to copy install command" }), onClick: async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token), icons: [FreeBsdIcon], }, { text: t`Manual setup instructions`, url: "https://beszel.dev/guide/agent-installation#binary", icons: [ExternalLinkIcon], }, ]} /> {/* Save */}
) } interface CopyButtonProps { text: string onClick: () => void dropdownItems: DropdownItem[] icon?: React.ReactElement } const CopyButton = memo((props: CopyButtonProps) => { return (
) }) ================================================ FILE: internal/site/src/components/alerts/alert-button.tsx ================================================ import { t } from "@lingui/core/macro" import { useStore } from "@nanostores/react" import { BellIcon } from "lucide-react" import { memo, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { $alerts } from "@/lib/stores" import { cn } from "@/lib/utils" import type { SystemRecord } from "@/types" import { AlertDialogContent } from "./alerts-sheet" export default memo(function AlertsButton({ system }: { system: SystemRecord }) { const [opened, setOpened] = useState(false) const alerts = useStore($alerts) const hasSystemAlert = alerts[system.id]?.size > 0 return useMemo( () => ( {opened && } ), [opened, hasSystemAlert] ) }) ================================================ FILE: internal/site/src/components/alerts/alerts-sheet.tsx ================================================ import { t } from "@lingui/core/macro" import { Plural, Trans } from "@lingui/react/macro" import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import { GlobeIcon, ServerIcon } from "lucide-react" import { lazy, memo, Suspense, useMemo, useState } from "react" import { $router, Link } from "@/components/router" import { Checkbox } from "@/components/ui/checkbox" import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { toast } from "@/components/ui/use-toast" import { alertInfo } from "@/lib/alerts" import { pb } from "@/lib/api" import { $alerts, $systems } from "@/lib/stores" import { cn, debounce } from "@/lib/utils" import type { AlertInfo, AlertRecord, SystemRecord } from "@/types" const Slider = lazy(() => import("@/components/ui/slider")) const endpoint = "/api/beszel/user-alerts" const alertDebounce = 400 const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[] const failedUpdateToast = (error: unknown) => { console.error(error) toast({ title: t`Failed to update alert`, description: t`Please check logs for more details.`, variant: "destructive", }) } /** Create or update alerts for a given name and systems */ const upsertAlerts = debounce( async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => { try { await pb.send<{ success: boolean }>(endpoint, { method: "POST", // overwrite is always true because we've done filtering client side body: { name, value, min, systems, overwrite: true }, }) } catch (error) { failedUpdateToast(error) } }, alertDebounce ) /** Delete alerts for a given name and systems */ const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => { try { await pb.send<{ success: boolean }>(endpoint, { method: "DELETE", body: { name, systems }, }) } catch (error) { failedUpdateToast(error) } }, alertDebounce) export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) { const alerts = useStore($alerts) const [overwriteExisting, setOverwriteExisting] = useState(false) const [currentTab, setCurrentTab] = useState("system") const systemAlerts = alerts[system.id] ?? new Map() // We need to keep a copy of alerts when we switch to global tab. If we always compare to // current alerts, it will only be updated when first checked, then won't be updated because // after that it exists. const alertsWhenGlobalSelected = useMemo(() => { return currentTab === "global" ? structuredClone(alerts) : alerts }, [currentTab]) return ( <> Alerts See{" "} notification settings {" "} to configure how you receive alerts. {system.name} All Systems
{alertKeys.map((name) => ( ))}
{alertKeys.map((name) => ( ))}
) }) export function AlertContent({ alertKey, data: alertData, system, alert, global = false, overwriteExisting = false, initialAlertsState = {}, }: { alertKey: string data: AlertInfo system: SystemRecord alert?: AlertRecord global?: boolean overwriteExisting?: boolean initialAlertsState?: Record> }) { const { name } = alertData const singleDescription = alertData.singleDesc?.() const [checked, setChecked] = useState(global ? false : !!alert) const [min, setMin] = useState(alert?.min || 10) const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : (alertData.start ?? 80))) const Icon = alertData.icon /** Get system ids to update */ function getSystemIds(): string[] { // if not global, update only the current system if (!global) { return [system.id] } // if global, update all systems when overwriteExisting is true // update only systems without an existing alert when overwriteExisting is false const allSystems = $systems.get() const systemIds: string[] = [] for (const system of allSystems) { if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) { systemIds.push(system.id) } } return systemIds } function sendUpsert(min: number, value: number) { const systems = getSystemIds() systems.length && upsertAlerts({ name: alertKey, value, min, systems, }) } return (
{checked && (
}> {!singleDescription && (

{alertData.invert ? ( Average drops below{" "} {value} {alertData.unit} ) : ( Average exceeds{" "} {value} {alertData.unit} )}

sendUpsert(min, val[0])} onValueChange={(val) => setValue(val[0])} step={alertData.step ?? 1} min={alertData.min ?? 1} max={alertData.max ?? 99} /> { let val = parseFloat(e.target.value) if (!Number.isNaN(val)) { if (alertData.max != null) val = Math.min(val, alertData.max) if (alertData.min != null) val = Math.max(val, alertData.min) setValue(val) sendUpsert(min, val) } }} step={alertData.step ?? 1} min={alertData.min ?? 1} max={alertData.max ?? 99} className="w-16 h-8 text-center px-1" />
)}

{singleDescription && ( <> {singleDescription} {` `} )} For {min}{" "}

sendUpsert(val[0], value)} onValueChange={(val) => setMin(val[0])} min={1} max={60} /> { let val = parseInt(e.target.value, 10) if (!Number.isNaN(val)) { val = Math.max(1, Math.min(val, 60)) setMin(val) sendUpsert(val, value) } }} min={1} max={60} className="w-16 h-8 text-center px-1" />
)}
) } ================================================ FILE: internal/site/src/components/alerts-history-columns.tsx ================================================ import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import type { ColumnDef } from "@tanstack/react-table" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { alertInfo } from "@/lib/alerts" import { cn, formatDuration, formatShortDate, toFixedFloat } from "@/lib/utils" import type { AlertsHistoryRecord } from "@/types" export const alertsHistoryColumns: ColumnDef[] = [ { accessorKey: "system", enableSorting: true, header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.expand?.system?.name || row.original.system}
), filterFn: (row, _, filterValue) => { const display = row.original.expand?.system?.name || row.original.system || "" return display.toLowerCase().includes(filterValue.toLowerCase()) }, }, { // accessorKey: "name", id: "name", accessorFn: (record) => { const name = record.name const info = alertInfo[name] return info?.name().replace("cpu", "CPU") || name }, header: ({ column }) => ( ), cell: ({ getValue, row }) => { const name = getValue() as string const info = alertInfo[row.original.name] const Icon = info?.icon return ( {Icon && } {name} ) }, }, { accessorKey: "value", enableSorting: false, header: () => ( ), cell({ row, getValue }) { const name = row.original.name if (name === "Status") { return {t`Down`} } const value = getValue() as number const unit = alertInfo[name]?.unit return ( {toFixedFloat(value, value < 10 ? 2 : 1)} {unit} ) }, }, { accessorKey: "state", enableSorting: true, sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0), header: ({ column }) => ( ), cell: ({ row }) => { const resolved = row.original.resolved return ( {/* {resolved ? : } */} {resolved ? Resolved : Active} ) }, }, { accessorKey: "created", accessorFn: (record) => formatShortDate(record.created), enableSorting: true, invertSorting: true, header: ({ column }) => ( ), cell: ({ getValue, row }) => ( {getValue() as string} ), }, { accessorKey: "resolved", enableSorting: true, invertSorting: true, header: ({ column }) => ( ), cell: ({ row, getValue }) => { const resolved = getValue() as string | null if (!resolved) { return null } return ( {formatShortDate(resolved)} ) }, }, { accessorKey: "duration", invertSorting: true, enableSorting: true, sortingFn: (rowA, rowB) => { const aCreated = new Date(rowA.original.created) const bCreated = new Date(rowB.original.created) const aResolved = rowA.original.resolved ? new Date(rowA.original.resolved) : null const bResolved = rowB.original.resolved ? new Date(rowB.original.resolved) : null const aDuration = aResolved ? aResolved.getTime() - aCreated.getTime() : null const bDuration = bResolved ? bResolved.getTime() - bCreated.getTime() : null if (!aDuration && bDuration) return -1 if (aDuration && !bDuration) return 1 return (aDuration || 0) - (bDuration || 0) }, header: ({ column }) => ( ), cell: ({ row }) => { const duration = formatDuration(row.original.created, row.original.resolved) if (!duration) { return null } return {duration} }, }, ] ================================================ FILE: internal/site/src/components/charts/area-chart.tsx ================================================ import { useMemo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, xAxis, } from "@/components/ui/chart" import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" import { AxisDomain } from "recharts/types/util/types" export type DataPoint = { label: string dataKey: (data: SystemStatsRecord) => number | undefined color: number | string opacity: number stackId?: string | number } export default function AreaChartDefault({ chartData, max, maxToggled, tickFormatter, contentFormatter, dataPoints, domain, legend, itemSorter, showTotal = false, reverseStackOrder = false, hideYAxis = false, }: // logRender = false, { chartData: ChartData max?: number maxToggled?: boolean tickFormatter: (value: number, index: number) => string contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string dataPoints?: DataPoint[] domain?: AxisDomain legend?: boolean showTotal?: boolean itemSorter?: (a: any, b: any) => number reverseStackOrder?: boolean hideYAxis?: boolean // logRender?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() // biome-ignore lint/correctness/useExhaustiveDependencies: ignore return useMemo(() => { if (chartData.systemStats.length === 0) { return null } // if (logRender) { // console.log("Rendered at", new Date()) // } return (
{!hideYAxis && ( updateYAxisWidth(tickFormatter(value, index))} tickLine={false} axisLine={false} /> )} {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={contentFormatter} showTotal={showTotal} /> } /> {dataPoints?.map((dataPoint) => { let { color } = dataPoint if (typeof color === "number") { color = `var(--chart-${color})` } return ( ) })} {legend && } />}
) }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal]) } ================================================ FILE: internal/site/src/components/charts/chart-time-select.tsx ================================================ import { useStore } from "@nanostores/react" import { HistoryIcon } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { $chartTime } from "@/lib/stores" import { chartTimeData, cn, compareSemVer, parseSemVer } from "@/lib/utils" import type { ChartTimes, SemVer } from "@/types" import { memo } from "react" export default memo(function ChartTimeSelect({ className, agentVersion, }: { className?: string agentVersion: SemVer }) { const chartTime = useStore($chartTime) // remove chart times that are not supported by the system agent version const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => { if (!minVersion) { return true } return compareSemVer(agentVersion, parseSemVer(minVersion)) >= 0 }) return ( ) }) ================================================ FILE: internal/site/src/components/charts/container-chart.tsx ================================================ // import Spinner from '../spinner' import { useStore } from "@nanostores/react" import { memo, useMemo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis, } from "@/components/ui/chart" import { ChartType, Unit } from "@/lib/enums" import { $containerFilter, $userSettings } from "@/lib/stores" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" import { Separator } from "../ui/separator" import { useYAxisWidth } from "./hooks" export default memo(function ContainerChart({ dataKey, chartData, chartType, chartConfig, unit = "%", }: { dataKey: string chartData: ChartData chartType: ChartType chartConfig: ChartConfig unit?: string }) { const filter = useStore($containerFilter) const userSettings = useStore($userSettings) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { containerData } = chartData const isNetChart = chartType === ChartType.Network // Filter with set lookup const filteredKeys = useMemo(() => { if (!filter) { return new Set() } const filterTerms = filter .toLowerCase() .split(" ") .filter((term) => term.length > 0) return new Set( Object.keys(chartConfig).filter((key) => { const keyLower = key.toLowerCase() return !filterTerms.some((term) => keyLower.includes(term)) }) ) }, [chartConfig, filter]) // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => { const obj = {} as { toolTipFormatter: (item: any, key: string) => React.ReactNode | string dataFunction: (key: string, data: any) => number | null tickFormatter: (value: any) => string } // tick formatter if (chartType === ChartType.CPU) { obj.tickFormatter = (value) => { const val = `${toFixedFloat(value, 2)}%` return updateYAxisWidth(val) } } else { const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes obj.tickFormatter = (val) => { const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart) return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`) } } // tooltip formatter if (isNetChart) { const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => { if (record?.b?.length && record.b.length >= 2) { return [Number(record.b[0]) || 0, Number(record.b[1]) || 0] } return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024] } const formatRxTx = (recv: number, sent: number) => { const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false) const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false) return ( {decimalString(receivedValue)} {receivedUnit} rx {decimalString(sentValue)} {sentUnit} tx ) } obj.toolTipFormatter = (item: any, key: string) => { try { if (key === "__total__") { let totalSent = 0 let totalRecv = 0 const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {} for (const [containerKey, value] of Object.entries(payloadData)) { if (!value || typeof value !== "object") { continue } // Skip filtered out containers if (filteredKeys.has(containerKey)) { continue } const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number }) totalSent += sent totalRecv += recv } return formatRxTx(totalRecv, totalSent) } const [sent, recv] = getRxTxBytes(item?.payload?.[key]) return formatRxTx(recv, sent) } catch (e) { return null } } } else if (chartType === ChartType.Memory) { obj.toolTipFormatter = (item: any) => { const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true) return `${decimalString(value)} ${unit}` } } else { obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}` } // data function if (isNetChart) { obj.dataFunction = (key: string, data: any) => { const payload = data[key] if (!payload) { return null } const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024 const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024 return sent + recv } } else { obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null } return obj }, [filteredKeys]) // console.log('rendered at', new Date()) if (containerData.length === 0) { return null } return (
{xAxis(chartData)} formatShortDate(data[0].payload.created)} // @ts-expect-error itemSorter={(a, b) => b.value - a.value} content={} /> {Object.keys(chartConfig).map((key) => { const filtered = filteredKeys.has(key) const fillOpacity = filtered ? 0.05 : 0.4 const strokeOpacity = filtered ? 0.1 : 1 return ( ) })}
) }) ================================================ FILE: internal/site/src/components/charts/disk-chart.tsx ================================================ import { useLingui } from "@lingui/react/macro" import { memo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { Unit } from "@/lib/enums" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function DiskChart({ dataKey, diskSize, chartData, }: { dataKey: string | ((data: SystemStatsRecord) => number | undefined) diskSize: number chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { t } = useLingui() // round to nearest GB if (diskSize >= 100) { diskSize = Math.round(diskSize) } if (chartData.systemStats.length === 0) { return null } return (
{ const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true) return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit) }} /> {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={({ value }) => { const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) return decimalString(convertedValue) + " " + unit }} /> } />
) }) ================================================ FILE: internal/site/src/components/charts/gpu-power-chart.tsx ================================================ import { memo, useMemo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, xAxis, } from "@/components/ui/chart" import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData, GPUData } from "@/types" import { useYAxisWidth } from "./hooks" import type { DataPoint } from "./line-chart" export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const packageKey = " package" const { gpuData, dataPoints } = useMemo(() => { const dataPoints = [] as DataPoint[] const gpuData = [] as Record[] const addedKeys = new Map() const addKey = (key: string, value: number) => { addedKeys.set(key, (addedKeys.get(key) ?? 0) + value) } for (const stats of chartData.systemStats) { const gpus = stats.stats?.g ?? {} const data = { created: stats.created } as Record for (const id in gpus) { const gpu = gpus[id] as GPUData data[gpu.n] = gpu addKey(gpu.n, gpu.p ?? 0) if (gpu.pp) { data[`${gpu.n}${packageKey}`] = gpu addKey(`${gpu.n}${packageKey}`, gpu.pp ?? 0) } } gpuData.push(data) } const sortedKeys = Array.from(addedKeys.entries()) .sort(([, a], [, b]) => b - a) .map(([key]) => key) for (let i = 0; i < sortedKeys.length; i++) { const id = sortedKeys[i] dataPoints.push({ label: id, dataKey: (gpuData: Record) => { return id.endsWith(packageKey) ? (gpuData[id]?.pp ?? 0) : (gpuData[id]?.p ?? 0) }, color: `hsl(${226 + (((i * 360) / addedKeys.size) % 360)}, 65%, 52%)`, }) } return { gpuData, dataPoints } }, [chartData]) if (chartData.systemStats.length === 0) { return null } return (
{ const val = toFixedFloat(value, 2) return updateYAxisWidth(`${val}W`) }} tickLine={false} axisLine={false} /> {xAxis(chartData)} b.value - a.value} content={ formatShortDate(data[0].payload.created)} contentFormatter={(item) => `${decimalString(item.value)}W`} // indicator="line" /> } /> {dataPoints.map((dataPoint) => ( ))} {dataPoints.length > 1 && } />}
) }) ================================================ FILE: internal/site/src/components/charts/hooks.ts ================================================ import { useMemo, useState } from "react" import type { ChartConfig } from "@/components/ui/chart" import type { ChartData, SystemStats, SystemStatsRecord } from "@/types" /** Chart configurations for CPU, memory, and network usage charts */ export interface ContainerChartConfigs { cpu: ChartConfig memory: ChartConfig network: ChartConfig } /** * Generates chart configurations for container metrics visualization * @param containerData - Array of container statistics data points * @returns Chart configurations for CPU, memory, and network metrics */ export function useContainerChartConfigs(containerData: ChartData["containerData"]): ContainerChartConfigs { return useMemo(() => { const configs = { cpu: {} as ChartConfig, memory: {} as ChartConfig, network: {} as ChartConfig, } // Aggregate usage metrics for each container const totalUsage = { cpu: new Map(), memory: new Map(), network: new Map(), } // Process each data point to calculate totals for (let i = 0; i < containerData.length; i++) { const stats = containerData[i] const containerNames = Object.keys(stats) for (let j = 0; j < containerNames.length; j++) { const containerName = containerNames[j] // Skip metadata field if (containerName === "created") { continue } const containerStats = stats[containerName] if (!containerStats) { continue } // Accumulate metrics for CPU, memory, and network const currentCpu = totalUsage.cpu.get(containerName) ?? 0 const currentMemory = totalUsage.memory.get(containerName) ?? 0 const currentNetwork = totalUsage.network.get(containerName) ?? 0 const sentBytes = containerStats.b?.[0] ?? (containerStats.ns ?? 0) * 1024 * 1024 const recvBytes = containerStats.b?.[1] ?? (containerStats.nr ?? 0) * 1024 * 1024 totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0)) totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0)) totalUsage.network.set(containerName, currentNetwork + sentBytes + recvBytes) } } // Generate chart configurations for each metric type Object.entries(totalUsage).forEach(([chartType, usageMap]) => { const sortedContainers = Array.from(usageMap.entries()).sort(([, a], [, b]) => b - a) const chartConfig = {} as Record const count = sortedContainers.length // Generate colors for each container for (let i = 0; i < count; i++) { const [containerName] = sortedContainers[i] const hue = ((i * 360) / count) % 360 chartConfig[containerName] = { label: containerName, color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`, } } configs[chartType as keyof typeof configs] = chartConfig }) return configs }, [containerData]) } /** Sets the correct width of the y axis in recharts based on the longest label */ export function useYAxisWidth() { const [yAxisWidth, setYAxisWidth] = useState(0) let maxChars = 0 let timeout: ReturnType function updateYAxisWidth(str: string) { if (str.length > maxChars) { maxChars = str.length const div = document.createElement("div") div.className = "text-xs tabular-nums tracking-tighter table sr-only" div.innerHTML = str clearTimeout(timeout) timeout = setTimeout(() => { document.body.appendChild(div) const width = div.offsetWidth + 24 if (width > yAxisWidth) { setYAxisWidth(div.offsetWidth + 24) } document.body.removeChild(div) }) } return str } return { yAxisWidth, updateYAxisWidth } } // Assures consistent colors for network interfaces export function useNetworkInterfaces(interfaces: SystemStats["ni"]) { const keys = Object.keys(interfaces ?? {}) const sortedKeys = keys.sort((a, b) => (interfaces?.[b]?.[3] ?? 0) - (interfaces?.[a]?.[3] ?? 0)) return { length: sortedKeys.length, data: (index = 3) => { return sortedKeys.map((key) => ({ label: key, dataKey: ({ stats }: SystemStatsRecord) => stats?.ni?.[key]?.[index], color: `hsl(${220 + (((sortedKeys.indexOf(key) * 360) / sortedKeys.length) % 360)}, 70%, 50%)`, opacity: 0.3, })) }, } } ================================================ FILE: internal/site/src/components/charts/line-chart.tsx ================================================ import { useMemo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, xAxis, } from "@/components/ui/chart" import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" export type DataPoint = { label: string dataKey: (data: SystemStatsRecord) => number | undefined color: number | string } export default function LineChartDefault({ chartData, max, maxToggled, tickFormatter, contentFormatter, dataPoints, domain, legend, itemSorter, }: // logRender = false, { chartData: ChartData max?: number maxToggled?: boolean tickFormatter: (value: number, index: number) => string contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string dataPoints?: DataPoint[] domain?: [number, number] legend?: boolean itemSorter?: (a: any, b: any) => number // logRender?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() // biome-ignore lint/correctness/useExhaustiveDependencies: ignore return useMemo(() => { if (chartData.systemStats.length === 0) { return null } // if (logRender) { // console.log("Rendered at", new Date()) // } return (
updateYAxisWidth(tickFormatter(value, index))} tickLine={false} axisLine={false} /> {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={contentFormatter} /> } /> {dataPoints?.map((dataPoint) => { let { color } = dataPoint if (typeof color === "number") { color = `var(--chart-${color})` } return ( ) })} {legend && } />}
) }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled]) } ================================================ FILE: internal/site/src/components/charts/load-average-chart.tsx ================================================ import { t } from "@lingui/core/macro" import { memo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, xAxis, } from "@/components/ui/chart" import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData, SystemStats } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const keys: { color: string; label: string }[] = [ { color: "hsl(271, 81%, 60%)", // Purple label: t({ message: `1 min`, comment: "Load average" }), }, { color: "hsl(217, 91%, 60%)", // Blue label: t({ message: `5 min`, comment: "Load average" }), }, { color: "hsl(25, 95%, 53%)", // Orange label: t({ message: `15 min`, comment: "Load average" }), }, ] return (
{ return updateYAxisWidth(String(toFixedFloat(value, 2))) }} tickLine={false} axisLine={false} /> {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={(item) => decimalString(item.value)} /> } /> {keys.map(({ color, label }, i) => ( value.stats?.la?.[i]} name={label} type="monotoneX" dot={false} strokeWidth={1.5} stroke={color} isAnimationActive={false} /> ))} } />
) }) ================================================ FILE: internal/site/src/components/charts/mem-chart.tsx ================================================ import { useLingui } from "@lingui/react/macro" import { memo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { Unit } from "@/lib/enums" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { t } = useLingui() const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) // console.log('rendered at', new Date()) if (chartData.systemStats.length === 0) { return null } return (
{/* {!yAxisSet && } */} {totalMem && ( { const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit) }} /> )} {xAxis(chartData)} a.order - b.order} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} contentFormatter={({ value }) => { // mem values are supplied as GB const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit }} showTotal={true} /> } /> (showMax ? stats?.mm : stats?.mu)} type="monotoneX" fill="var(--chart-2)" fillOpacity={0.4} stroke="var(--chart-2)" stackId="1" isAnimationActive={false} /> {/* {chartData.systemStats.at(-1)?.stats.mz && ( */} (showMax ? null : stats?.mz)} type="monotoneX" fill="hsla(175 60% 45% / 0.8)" fillOpacity={0.5} stroke="hsla(175 60% 45% / 0.8)" stackId="1" isAnimationActive={false} /> {/* )} */} (showMax ? null : stats?.mb)} type="monotoneX" fill="hsla(160 60% 45% / 0.5)" fillOpacity={0.4} stroke="hsla(160 60% 45% / 0.5)" stackId="1" isAnimationActive={false} /> {/* } /> */}
) }) ================================================ FILE: internal/site/src/components/charts/swap-chart.tsx ================================================ import { t } from "@lingui/core/macro" import { useStore } from "@nanostores/react" import { memo } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { $userSettings } from "@/lib/stores" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const userSettings = useStore($userSettings) if (chartData.systemStats.length === 0) { return null } return (
toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]} width={yAxisWidth} tickLine={false} axisLine={false} tickFormatter={(value) => { const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true) return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit) }} /> {xAxis(chartData)} formatShortDate(data[0].payload.created)} contentFormatter={({ value }) => { // mem values are supplied as GB const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true) return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit }} // indicator="line" /> } />
) }) ================================================ FILE: internal/site/src/components/charts/temperature-chart.tsx ================================================ import { useStore } from "@nanostores/react" import { memo, useMemo } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, xAxis, } from "@/components/ui/chart" import { $temperatureFilter, $userSettings } from "@/lib/stores" import { chartMargin, cn, decimalString, formatShortDate, formatTemperature, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { const filter = useStore($temperatureFilter) const userSettings = useStore($userSettings) const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() if (chartData.systemStats.length === 0) { return null } /** Format temperature data for chart and assign colors */ const newChartData = useMemo(() => { const newChartData = { data: [], colors: {} } as { data: Record[] colors: Record } const tempSums = {} as Record for (const data of chartData.systemStats) { const newData = { created: data.created } as Record const keys = Object.keys(data.stats?.t ?? {}) for (let i = 0; i < keys.length; i++) { const key = keys[i] newData[key] = data.stats.t![key] tempSums[key] = (tempSums[key] ?? 0) + newData[key] } newChartData.data.push(newData) } const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) for (const key of keys) { newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` } return newChartData }, [chartData]) const colors = Object.keys(newChartData.colors) // console.log('rendered at', new Date()) return (
{ const { value, unit } = formatTemperature(val, userSettings.unitTemp) return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit) }} tickLine={false} axisLine={false} /> {xAxis(chartData)} b.value - a.value} content={ formatShortDate(data[0].payload.created)} contentFormatter={(item) => { const { value, unit } = formatTemperature(item.value, userSettings.unitTemp) return decimalString(value) + " " + unit }} filter={filter} /> } /> {colors.map((key) => { const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : [] const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term)) const strokeOpacity = filtered ? 0.1 : 1 return ( ) })} {colors.length < 12 && } />}
) }) ================================================ FILE: internal/site/src/components/command-palette.tsx ================================================ import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { getPagePath } from "@nanostores/router" import { DialogDescription } from "@radix-ui/react-dialog" import { AlertOctagonIcon, BookIcon, ContainerIcon, DatabaseBackupIcon, FingerprintIcon, HardDriveIcon, LogsIcon, MailIcon, Server, ServerIcon, SettingsIcon, UsersIcon, } from "lucide-react" import { memo, useEffect, useMemo } from "react" import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, CommandShortcut, } from "@/components/ui/command" import { isAdmin } from "@/lib/api" import { $systems } from "@/lib/stores" import { getHostDisplayValue, listen } from "@/lib/utils" import { $router, basePath, navigate, prependBasePath } from "./router" export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen(!open) } } return listen(document, "keydown", down) }, [open, setOpen]) return useMemo(() => { const systems = $systems.get() const SettingsShortcut = ( Settings ) const AdminShortcut = ( Admin ) return ( Command palette {systems.length > 0 && ( <> {systems.map((system) => ( { navigate(getPagePath($router, "system", { id: system.id })) setOpen(false) }} > {system.name} {getHostDisplayValue(system)} ))} )} { navigate(basePath) setOpen(false) }} > All Systems Page { navigate(getPagePath($router, "containers")) setOpen(false) }} > All Containers Page { navigate(getPagePath($router, "smart")) setOpen(false) }} > S.M.A.R.T. Page { navigate(getPagePath($router, "settings", { name: "general" })) setOpen(false) }} > Settings {SettingsShortcut} { navigate(getPagePath($router, "settings", { name: "notifications" })) setOpen(false) }} > Notifications {SettingsShortcut} { navigate(getPagePath($router, "settings", { name: "tokens" })) setOpen(false) }} > Tokens & Fingerprints {SettingsShortcut} { navigate(getPagePath($router, "settings", { name: "alert-history" })) setOpen(false) }} > Alert History {SettingsShortcut} { window.location.href = "https://beszel.dev/guide/what-is-beszel" }} > Documentation beszel.dev {isAdmin() && ( <> { setOpen(false) window.open(prependBasePath("/_/"), "_blank") }} > Users {AdminShortcut} { setOpen(false) window.open(prependBasePath("/_/#/logs"), "_blank") }} > Logs {AdminShortcut} { setOpen(false) window.open(prependBasePath("/_/#/settings/backups"), "_blank") }} > Backups {AdminShortcut} { setOpen(false) window.open(prependBasePath("/_/#/settings/mail"), "_blank") }} > SMTP settings {AdminShortcut} )} No results found. ) }, [open]) }) ================================================ FILE: internal/site/src/components/containers-table/containers-table-columns.tsx ================================================ import type { Column, ColumnDef } from "@tanstack/react-table" import { Button } from "@/components/ui/button" import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils" import type { ContainerRecord } from "@/types" import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { ClockIcon, ContainerIcon, CpuIcon, LayersIcon, MemoryStickIcon, ServerIcon, ShieldCheckIcon, } from "lucide-react" import { EthernetIcon, HourglassIcon, SquareArrowRightEnterIcon } from "../ui/icons" import { Badge } from "../ui/badge" import { t } from "@lingui/core/macro" import { $allSystemsById, $longestSystemNameLen } from "@/lib/stores" import { useStore } from "@nanostores/react" import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip" // Unit names and their corresponding number of seconds for converting docker status strings const unitSeconds = [ ["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000], ] as const // Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.) function getStatusValue(status: string): number { const [_, num, unit] = status.split(" ") // Docker uses "a" or "an" instead of "1" for singular units (e.g., "Up a minute", "Up an hour") const numValue = num === "a" || num === "an" ? 1 : Number(num) for (const [unitName, value] of unitSeconds) { if (unit.startsWith(unitName)) { return numValue * value } } return 0 } export const containerChartCols: ColumnDef[] = [ { id: "name", sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), accessorFn: (record) => record.name, header: ({ column }) => , cell: ({ getValue }) => { return {getValue() as string} }, }, { id: "system", accessorFn: (record) => record.system, sortingFn: (a, b) => { const allSystems = $allSystemsById.get() const systemNameA = allSystems[a.original.system]?.name ?? "" const systemNameB = allSystems[b.original.system]?.name ?? "" return systemNameA.localeCompare(systemNameB) }, header: ({ column }) => , cell: ({ getValue }) => { const allSystems = useStore($allSystemsById) const longestName = useStore($longestSystemNameLen) return (
{allSystems[getValue() as string]?.name ?? ""}
) }, }, // { // id: "id", // accessorFn: (record) => record.id, // sortingFn: (a, b) => a.original.id.localeCompare(b.original.id), // header: ({ column }) => , // cell: ({ getValue }) => { // return {getValue() as string} // }, // }, { id: "cpu", accessorFn: (record) => record.cpu, invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => { const val = getValue() as number return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} }, }, { id: "memory", accessorFn: (record) => record.memory, invertSorting: true, header: ({ column }) => , cell: ({ getValue }) => { const val = getValue() as number const formatted = formatBytes(val, false, undefined, true) return ( {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} ) }, }, { id: "net", accessorFn: (record) => record.net, invertSorting: true, header: ({ column }) => , minSize: 112, cell: ({ getValue }) => { const val = getValue() as number const formatted = formatBytes(val, true, undefined, false) return (
{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}
) }, }, { id: "health", invertSorting: true, accessorFn: (record) => record.health, header: ({ column }) => , minSize: 121, cell: ({ getValue }) => { const healthValue = getValue() as number const healthStatus = ContainerHealthLabels[healthValue] || "Unknown" return ( {healthStatus} ) }, }, { id: "ports", accessorFn: (record) => record.ports || undefined, header: ({ column }) => ( ), sortingFn: (a, b) => getPortValue(a.original.ports) - getPortValue(b.original.ports), minSize: 147, cell: ({ getValue }) => { const val = getValue() as string | undefined if (!val) { return
-
} const className = "ms-1 w-27 block truncate tabular-nums" if (val.length > 14) { return ( {val} {val} ) } return {val} }, }, { id: "image", sortingFn: (a, b) => a.original.image.localeCompare(b.original.image), accessorFn: (record) => record.image, header: ({ column }) => ( ), cell: ({ getValue }) => { const val = getValue() as string return (
{val}
) }, }, { id: "status", accessorFn: (record) => record.status, invertSorting: true, sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status), header: ({ column }) => , cell: ({ getValue }) => { return {getValue() as string} }, }, { id: "updated", invertSorting: true, accessorFn: (record) => record.updated, header: ({ column }) => , cell: ({ getValue }) => { const timestamp = getValue() as number return {hourWithSeconds(new Date(timestamp).toISOString())} }, }, ] function HeaderButton({ column, name, Icon, }: { column: Column name: string Icon: React.ElementType }) { const isSorted = column.getIsSorted() return ( ) } /** * Convert port string to a number for sorting. * Handles formats like "80", "127.0.0.1:80", and "80, 443" (takes the first mapping). */ function getPortValue(ports: string | undefined): number { if (!ports) { return 0 } const first = ports.includes(",") ? ports.substring(0, ports.indexOf(",")) : ports const colonIndex = first.lastIndexOf(":") const portStr = colonIndex === -1 ? first : first.substring(colonIndex + 1) return Number(portStr) || 0 } ================================================ FILE: internal/site/src/components/containers-table/containers-table.tsx ================================================ /** biome-ignore-all lint/security/noDangerouslySetInnerHtml: html comes directly from docker via agent */ import { t } from "@lingui/core/macro" import { Trans } from "@lingui/react/macro" import { type ColumnFiltersState, flexRender, getCoreRowModel, getFilteredRowModel, getSortedRowModel, type Row, type SortingState, type Table as TableType, useReactTable, type VisibilityState, } from "@tanstack/react-table" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { memo, type RefObject, useEffect, useRef, useState } from "react" import { Input } from "@/components/ui/input" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { pb } from "@/lib/api" import type { ContainerRecord } from "@/types" import { containerChartCols } from "@/components/containers-table/containers-table-columns" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums" import { cn, useBrowserStorage } from "@/lib/utils" import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet" import { Dialog, DialogContent, DialogTitle } from "../ui/dialog" import { Button } from "@/components/ui/button" import { $allSystemsById } from "@/lib/stores" import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react" import { Separator } from "../ui/separator" import { $router, Link } from "../router" import { listenKeys } from "nanostores" import { getPagePath } from "@nanostores/router" const syntaxTheme = "github-dark-dimmed" export default function ContainersTable({ systemId }: { systemId?: string }) { const loadTime = Date.now() const [data, setData] = useState(undefined) const [sorting, setSorting] = useBrowserStorage( `sort-c-${systemId ? 1 : 0}`, [{ id: systemId ? "name" : "system", desc: false }], sessionStorage ) const [columnFilters, setColumnFilters] = useState([]) const [columnVisibility, setColumnVisibility] = useState({}) // Hide ports column if no ports are present useEffect(() => { if (data) { const hasPorts = data.some((container) => container.ports) setColumnVisibility((prev) => { if (prev.ports === hasPorts) { return prev } return { ...prev, ports: hasPorts } }) } }, [data]) const [rowSelection, setRowSelection] = useState({}) const [globalFilter, setGlobalFilter] = useState("") useEffect(() => { function fetchData(systemId?: string) { pb.collection("containers") .getList(0, 2000, { fields: "id,name,image,ports,cpu,memory,net,health,status,system,updated", filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, }) .then(({ items }) => { if (items.length === 0) { setData((curItems) => { if (systemId) { return curItems?.filter((item) => item.system !== systemId) ?? [] } return [] }) return } setData((curItems) => { const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) const containerIds = new Set() const newItems: ContainerRecord[] = [] for (const item of items) { if (Math.abs(lastUpdated - item.updated) < 70_000) { containerIds.add(item.id) newItems.push(item) } } for (const item of curItems ?? []) { if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) { newItems.push(item) } } return newItems }) }) } // initial load fetchData(systemId) // if no systemId, pull system containers after every system update if (!systemId) { return $allSystemsById.listen((_value, _oldValue, systemId) => { // exclude initial load of systems if (Date.now() - loadTime > 500) { fetchData(systemId) } }) } // if systemId, fetch containers after the system is updated return listenKeys($allSystemsById, [systemId], (_newSystems) => { fetchData(systemId) }) }, []) const table = useReactTable({ data: data ?? [], columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)), getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, defaultColumn: { sortUndefined: "last", size: 100, minSize: 0, }, state: { sorting, columnFilters, columnVisibility, rowSelection, globalFilter, }, onGlobalFilterChange: setGlobalFilter, globalFilterFn: (row, _columnId, filterValue) => { const container = row.original const systemName = $allSystemsById.get()[container.system]?.name ?? "" const id = container.id ?? "" const name = container.name ?? "" const status = container.status ?? "" const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? "" const image = container.image ?? "" const ports = container.ports ?? "" const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image} ${ports}`.toLowerCase() return (filterValue as string) .toLowerCase() .split(" ") .every((term) => searchString.includes(term)) }, }) const rows = table.getRowModel().rows const visibleColumns = table.getVisibleLeafColumns() return (
All Containers Click on a container to view more information.
setGlobalFilter(e.target.value)} className="ps-4 pe-10 w-full" /> {globalFilter && ( )}
) } const AllContainersTable = memo(function AllContainersTable({ table, rows, colLength, data, }: { table: TableType rows: Row[] colLength: number data: ContainerRecord[] | undefined }) { // The virtualizer will need a reference to the scrollable container element const scrollRef = useRef(null) const activeContainer = useRef(null) const [sheetOpen, setSheetOpen] = useState(false) const openSheet = (container: ContainerRecord) => { activeContainer.current = container setSheetOpen(true) } const virtualizer = useVirtualizer({ count: rows.length, estimateSize: () => 54, getScrollElement: () => scrollRef.current, overscan: 5, }) const virtualRows = virtualizer.getVirtualItems() const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) return (
2) && "min-h-50" )} ref={scrollRef} > {/* add header height to table size */}
{rows.length ? ( virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] return }) ) : ( {data ? ( No results. ) : ( )} )}
) }) async function getLogsHtml(container: ContainerRecord): Promise { try { const [{ highlighter }, logsHtml] = await Promise.all([ import("@/lib/shiki"), pb.send<{ logs: string }>("/api/beszel/containers/logs", { system: container.system, container: container.id, }), ]) return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.` } catch (error) { console.error(error) return "" } } async function getInfoHtml(container: ContainerRecord): Promise { try { let [{ highlighter }, { info }] = await Promise.all([ import("@/lib/shiki"), pb.send<{ info: string }>("/api/beszel/containers/info", { system: container.system, container: container.id, }), ]) try { info = JSON.stringify(JSON.parse(info), null, 2) } catch (_) {} return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.` } catch (error) { console.error(error) return "" } } function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer, }: { sheetOpen: boolean setSheetOpen: (open: boolean) => void activeContainer: RefObject }) { const [logsDisplay, setLogsDisplay] = useState("") const [infoDisplay, setInfoDisplay] = useState("") const [logsFullscreenOpen, setLogsFullscreenOpen] = useState(false) const [infoFullscreenOpen, setInfoFullscreenOpen] = useState(false) const [isRefreshingLogs, setIsRefreshingLogs] = useState(false) const logsContainerRef = useRef(null) const container = activeContainer.current function scrollLogsToBottom() { if (logsContainerRef.current) { logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight }) } } const refreshLogs = async () => { if (!container) return setIsRefreshingLogs(true) const startTime = Date.now() try { const logsHtml = await getLogsHtml(container) setLogsDisplay(logsHtml) setTimeout(scrollLogsToBottom, 20) } catch (error) { console.error(error) } finally { // Ensure minimum spin duration of 800ms const elapsed = Date.now() - startTime const remaining = Math.max(0, 500 - elapsed) setTimeout(() => { setIsRefreshingLogs(false) }, remaining) } } useEffect(() => { setLogsDisplay("") setInfoDisplay("") if (!container) return ;(async () => { const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)]) setLogsDisplay(logsHtml) setInfoDisplay(infoHtml) setTimeout(scrollLogsToBottom, 20) })() }, [container]) if (!container) return null return ( <> {container.name} {$allSystemsById.get()[container.system]?.name ?? ""} {container.status} {container.image} {container.id} {/* {container.ports && ( <> {container.ports} )} */} {/* {ContainerHealthLabels[container.health as ContainerHealth]} */}

{t`Logs`}

{t`Detail`}

) } function ContainersTableHead({ table }: { table: TableType }) { return (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ) })} ))}
) } const ContainerTableRow = memo(function ContainerTableRow({ row, virtualRow, openSheet, }: { row: Row virtualRow: VirtualItem openSheet: (container: ContainerRecord) => void }) { return ( openSheet(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ) }) function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing, }: { open: boolean onOpenChange: (open: boolean) => void logsDisplay: string containerName: string onRefresh: () => void | Promise isRefreshing: boolean }) { const outerContainerRef = useRef(null) useEffect(() => { if (open && logsDisplay) { // Scroll the outer container to bottom const scrollToBottom = () => { if (outerContainerRef.current) { outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight } } setTimeout(scrollToBottom, 50) } }, [open, logsDisplay]) return ( {containerName} logs
) } function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName, }: { open: boolean onOpenChange: (open: boolean) => void infoDisplay: string containerName: string }) { return ( {containerName} info
) } ================================================ FILE: internal/site/src/components/copy-to-clipboard.tsx ================================================ import { Trans } from "@lingui/react/macro" import { useEffect, useMemo, useRef } from "react" import { $copyContent } from "@/lib/stores" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" import { Textarea } from "./ui/textarea" export default function CopyToClipboard({ content }: { content: string }) { return ( Copy text Automatic copy requires a secure context. ) } function CopyTextarea({ content }: { content: string }) { const textareaRef = useRef(null) const rows = useMemo(() => { return content.split("\n").length }, [content]) useEffect(() => { if (textareaRef.current) { textareaRef.current.select() } }, [textareaRef]) useEffect(() => { return () => $copyContent.set("") }, []) return (