Compare commits

..

101 Commits

Author SHA1 Message Date
henrygd
80338d36aa release 0.12.1 :) 2025-07-25 19:23:53 -04:00
henrygd
f0d2c242e8 update language files 2025-07-25 19:19:49 -04:00
zoixc
559f83d99c Updated Russian translations 2025-07-25 19:14:08 -04:00
Kamil
d3a751ee6c Updated Polish translations 2025-07-25 19:13:18 -04:00
Sven van Ginkel
fb70a166fa Updated Dutch translations 2025-07-25 19:13:18 -04:00
Alberto Rizzi
c12457b707 Updated Italian translations 2025-07-25 19:08:03 -04:00
Lucas Pedro
3e53d73d56 Updated Portuguese translations 2025-07-25 19:03:45 -04:00
Mikki Alexander Mousing Sørensen
80338c5e98 Updated Danish translations 2025-07-25 19:01:34 -04:00
NickAss512
249cd8ad19 Updated Czech translations
Co-authored-by: Gear <88455107+GearCzech@users.noreply.github.com>
2025-07-25 19:01:34 -04:00
henrygd
ccdff46370 add TOKEN_FILE env var 2025-07-25 18:26:31 -04:00
henrygd
91679b5cc0 refactor load average handling (#982)
- Transitioned from individual load average fields (LoadAvg1, LoadAvg5,
LoadAvg15) to a single array (LoadAvg)
- Ensure load is displayed when all zero values.
2025-07-25 18:07:29 -04:00
henrygd
6953edf59e sort token / fingerprint table by system name 2025-07-25 15:53:40 -04:00
henrygd
b91c77ec92 make sure only 200 records are returned for alert history table 2025-07-25 15:43:19 -04:00
henrygd
3ac0b185d1 fix oidc icon display issue (#990) 2025-07-25 13:55:02 -04:00
henrygd
1e675cabb5 refactor agent data directory resolution (#991) 2025-07-25 13:37:23 -04:00
henrygd
5f44965c2c improve table formatting and fix #983
- should fix NaN display bug for dasboard cpu
- standardize decimals for dash meters
2025-07-25 00:29:08 -04:00
henrygd
f080929296 Update goreleaser configuration
- Removed the name field for brew.
- Enabled pull requests for winget.
2025-07-24 22:31:27 -04:00
henrygd
f055658eba update bun.lockb and package-lock.json 2025-07-24 20:03:19 -04:00
henrygd
e430c747fe 0.12.0 release :) 2025-07-24 19:51:51 -04:00
henrygd
ca62b1db36 update translations 2025-07-24 19:47:25 -04:00
henrygd
38569b7057 update install scripts for 0.12.0 release 2025-07-24 18:39:13 -04:00
henrygd
203244090f update alert history icon 2025-07-24 18:11:39 -04:00
henrygd
2bed722045 add windows upgrade scripts 2025-07-24 17:36:17 -04:00
henrygd
13f3a52760 remove beta scripts from copy/paste commands 2025-07-24 17:35:21 -04:00
henrygd
16b9827c70 bump migration name for 0.12.0 release 2025-07-24 17:35:06 -04:00
henrygd
0fc352d7fc update js deps 2025-07-23 19:51:19 -04:00
凉心
8a2bee11d4 Update viewport meta to prevent input zoom on iOS (#858)
- Prevents page zoom when tapping input fields on iPhone
2025-07-23 19:39:39 -04:00
henrygd
485f7d16ff add tests for agent/client.go and hub/ws/ws.go 2025-07-23 15:54:02 -04:00
henrygd
46fdc94cb8 improve bandwidth measurement precision + refactor default area chart 2025-07-23 15:52:02 -04:00
henrygd
261f7fb76c update go dependencies 2025-07-21 20:14:31 -04:00
henrygd
18d9258907 Alert history updates 2025-07-21 20:07:52 -04:00
Sven van Ginkel
9d7fb8ab80 [Feature] Add Alerts History page (#973)
* Add alert history

* refactor

* fix one colunm

* update migration

* add retention
2025-07-20 19:20:51 -04:00
henrygd
3730a78e5a update load avg display and include it in longer records 2025-07-16 21:24:42 -04:00
Sven van Ginkel
7cdd0907e8 [Feature][0.12.0-Beta] Enhance Load Average Display, Charting & Alert Grouping (#960)
* Add 1m load

* update alart dialog

* fix null data

* Remove omit zero

* change table and alert view
2025-07-16 16:03:26 -04:00
henrygd
3586f73f30 check for malformed sensor names on darwin (#796) 2025-07-16 14:56:13 -04:00
henrygd
752ccc6beb hide tokens page for readonly users 2025-07-16 14:41:23 -04:00
henrygd
f577476c81 clear systems from memory on logout (#970) 2025-07-16 14:39:15 -04:00
henrygd
49ae424698 refactor: consolidate fixedFloat funcs + remove trailing zeroes from y axis 2025-07-15 21:46:41 -04:00
henrygd
d4fd19522b fix races when loading user settings 2025-07-15 21:29:51 -04:00
henrygd
5c047e4afd Refactor unit preferences and update chart components
* Refactor user settings to use enum for unit preferences (temperature,
network, disk).
* Update chart components to utilize new unit formatting functions
* Remove deprecated conversion functions and streamline unit handling
across charts.
* Enhance settings page to allow user selection of unit preferences with
updated labels.
2025-07-15 18:57:37 -04:00
Anish Chanda
6576141f54 Adds display unit preference (#938)
* Adds temperature unit preference

* add unit preferences for networking

* adds options for MB/s and bps.

* supports disk throughput unit preferences
2025-07-14 14:46:13 -04:00
Sven van Ginkel
926e807020 [Chore] Fix CI labels (#964) 2025-07-14 14:03:56 -04:00
Sven van Ginkel
d91847c6c5 [Chrore] Improve Issue Templates with Categorization and Label Automation (#939)
* Update templates

* update script

* update labels

* add PR template

* update dropdown
2025-07-13 19:51:29 -04:00
Alexander Mnich
0abd88270c fix docker edge tag (#959) 2025-07-13 11:18:25 -04:00
henrygd
806c4e51c5 v0.12.0-beta2 release 2025-07-12 21:19:57 -04:00
henrygd
6520783fe9 small ui tweaks 2025-07-12 21:16:40 -04:00
henrygd
48c8a3a4a5 update package-lock.json 2025-07-12 21:06:01 -04:00
henrygd
e0c839f78c add skip_upload: auto to goreleaser homebrew config 2025-07-12 19:56:48 -04:00
NeMeow
1ba362bafe Add 5m and 10m load avg alerts and table values (#816)
Co-authored-by: henrygd <hank@henrygd.me>
2025-07-12 19:47:33 -04:00
henrygd
b5d55ead4a send websocket close message to agent 2025-07-12 18:49:40 -04:00
henrygd
4f879ccc66 Fix universal tokens registering multiple systems 2025-07-12 17:11:53 -04:00
henrygd
cd9e0f7b5b fix typo in install scripts 2025-07-10 15:10:44 -04:00
henrygd
780644eeae Update translations on tokens page 2025-07-08 21:32:16 -04:00
henrygd
71f081da20 add :edge image tag 2025-07-08 20:40:51 -04:00
henrygd
11c61bcf42 update translations for 0.12.0 beta 2025-07-08 19:28:30 -04:00
henrygd
402a1584d7 Add CBOR and agent initiated WebSocket connections (#51, #490, #646, #845, etc)
- Add version exchange between hub and agent.
- Introduce ConnectionManager for managing WebSocket and SSH connections.
- Implement fingerprint generation and storage in agent.
- Create expiry map package to store universal tokens.
- Update config.yml configuration to include tokens.
- Enhance system management with new methods for handling system states and alerts.
- Update front-end components to support token / fingerprint management features.
- Introduce utility functions for token generation and hub URL retrieval.

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-07-08 18:41:36 -04:00
henrygd
99d61a0193 update makefile and other tiny refactoring
- remove goccy/json dep
- add explicit types in gpu.go
2025-07-08 18:21:14 -04:00
henrygd
5ddb200a75 improve memory efficiency of records.go 2025-07-08 18:03:49 -04:00
henrygd
faa247dbda add agent data directory handling 2025-07-08 16:45:14 -04:00
henrygd
6d1cec3c42 new agent healthcheck to support non-ssh connections 2025-07-08 16:43:33 -04:00
henrygd
529df84273 make sure agent container has /tmp directory 2025-07-08 15:35:50 -04:00
henrygd
e0e21eedd6 update deps 2025-07-08 15:35:06 -04:00
henrygd
4356ffbe9b add install scripts for beta versions 2025-07-08 15:33:26 -04:00
henrygd
be1366b785 update docker workflow to clearly handle beta / rc tags 2025-07-08 15:28:23 -04:00
henrygd
3dc7e02ed0 exclude bond network interfaces by default 2025-07-08 15:27:33 -04:00
henrygd
d67d638a6b Refactor dockerManager
- Introduced a reusable buffer and JSON decoder in dockerManager for efficient decoding of Docker API responses.
- Adjusted network statistics calculations to ensure accurate data handling.
2025-07-08 15:27:01 -04:00
henrygd
7b36036455 add vulncheck workflow 2025-07-03 21:06:26 -04:00
Sven van Ginkel
1b58560acf Update MakeFile to serve outside local host (#934) 2025-06-27 21:41:16 -04:00
henrygd
1627c41f84 fix gpu name issue introduced in previous commit 2025-06-27 18:00:47 -04:00
henrygd
4395520a28 Probable fix for Jetson gpu issue (#895) 2025-06-26 22:11:48 -04:00
Alexander Mnich
8c52f30a71 add GITHUB_TOKEN fallback for goreleaser (#925)
adding the fallback to the GITHUB_TOKEN allows execution of goreleaser in a fork without additional configuration
2025-06-26 21:03:19 -04:00
SSU
46316ebffa fix(install): suppress scoop output to avoid nssm path pollution (#918)
Suppressed the output of “scoop install beszel-agent” to ensure the NSSM service path
contains only the executable location.

Closes #915

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

Co-authored-by: nhas <jordanatararimu@gmail.com>
2025-05-07 20:03:21 -04:00
henrygd
c0a6153a43 Update goreleaser configuration for beszel-agent to include restart delay and process type 2025-05-05 20:22:15 -04:00
henrygd
df334caca6 update install-agent.ps1 to support installing as admin (#797) 2025-05-05 20:21:37 -04:00
henrygd
ffb3ec0477 Fix broken link to notifications when using base path 2025-05-02 20:02:23 -04:00
henrygd
3a97edd0d5 add winget support to windows install script 2025-05-01 17:40:20 -04:00
henrygd
ab1d1c1273 Remove PrivateTmp setting from Systemd rules in install-agent.sh
Allows sharing socket in /tmp
2025-05-01 17:00:08 -04:00
henrygd
0fb39edae4 rename ssh imports in server.go 2025-04-30 18:09:25 -04:00
henrygd
3a977a8e1f goreleaser: update deprecated format field 2025-04-30 16:15:37 -04:00
henrygd
081979de24 Add winget configuration for beszel-agent 2025-04-30 15:00:26 -04:00
henrygd
23fe189797 Add SELinux context management to install-agent.sh (#788)
- Introduced functions to set and clean up SELinux contexts.
- Added SELinux context checks during the update process (systemd only).
- Updated the service execution command to use a dedicated update script.
2025-04-29 18:59:36 -04:00
henrygd
e9d429b9b8 Enhance service start check in install-agent.ps1
- Added logic to handle service start failures and check status with retries.
- Improved user feedback for service status during startup process.
2025-04-28 21:47:02 -04:00
henrygd
99202c85b6 re-enable docker image creation workflow 2025-04-28 21:16:32 -04:00
143 changed files with 20617 additions and 5218 deletions

View File

@@ -1,8 +1,19 @@
name: 🐛 Bug report
description: Report a new bug or issue.
title: '[Bug]: '
labels: ['bug']
labels: ['bug', "needs confirmation"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: |
@@ -43,6 +54,39 @@ body:
3. Pour it into a cup.
validations:
required: true
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true
- type: input
id: system
attributes:
@@ -61,7 +105,6 @@ body:
id: install-method
attributes:
label: Installation method
default: 0
options:
- Docker
- Binary

View File

@@ -1,8 +1,19 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement"]
labels: ["enhancement", "needs review"]
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
validations:
required: true
- type: markdown
attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
@@ -11,8 +22,55 @@ body:
label: Describe the feature you would like to see
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
- type: textarea
attributes:
label: Describe how you would like to see this feature implemented
validations:
required: true
- type: textarea
id: logs
attributes:
label: Screenshots
description: Please attach any relevant screenshots, such as images from your current solution or similar implementations.
validations:
required: false
- type: dropdown
id: category
attributes:
label: Category
description: Which category does this relate to most?
options:
- Metrics
- Charts & Visualization
- Settings & Configuration
- Notifications & Alerts
- Authentication
- Installation
- Performance
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: metrics
attributes:
label: Affected Metrics
description: If applicable, which specific metric does this relate to most?
options:
- CPU
- Memory
- Storage
- Network
- Containers
- GPU
- Sensors
- Other
validations:
required: true

33
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,33 @@
## 📃 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.

View File

@@ -3,7 +3,7 @@ name: Make docker images
on:
push:
tags:
- 'xv*'
- "v*"
jobs:
build:
@@ -65,6 +65,7 @@ jobs:
with:
images: ${{ matrix.image }}
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -84,7 +85,7 @@ jobs:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: '${{ matrix.context }}'
context: "${{ matrix.context }}"
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: ${{ github.ref_type == 'tag' }}

View File

@@ -0,0 +1,43 @@
name: 'Issue and PR Maintenance'
on:
schedule:
- cron: '0 0 * * *' # runs at midnight UTC
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
close-stale:
name: Close Stale Issues
runs-on: ubuntu-24.04
steps:
- name: Close Stale Issues
uses: actions/stale@v9
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
# Labels
stale-issue-label: 'stale'
remove-stale-when-updated: true
only-issue-labels: 'awaiting-requester'
# Exemptions
exempt-assignees: true
exempt-milestones: true

View File

@@ -0,0 +1,82 @@
name: Label issues from dropdowns
on:
issues:
types: [opened]
jobs:
label_from_dropdown:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Apply labels based on dropdown choices
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.issue.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
// Get the issue body
const body = context.payload.issue.body;
// Helper to find dropdown value in the body (assuming markdown format)
function extractSectionValue(heading) {
const regex = new RegExp(`### ${heading}\\s+([\\s\\S]*?)(?:\\n###|$)`, 'i');
const match = body.match(regex);
if (match) {
// Get the first non-empty line after the heading
const lines = match[1].split('\n').map(l => l.trim()).filter(Boolean);
return lines[0] || null;
}
return null;
}
// Extract dropdown selections
const category = extractSectionValue('Category');
const metrics = extractSectionValue('Affected Metrics');
const component = extractSectionValue('Component');
// Build labels to add
let labelsToAdd = [];
if (category) labelsToAdd.push(category);
if (metrics) labelsToAdd.push(metrics);
if (component) labelsToAdd.push(component);
// Get existing labels in the repo
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
owner,
repo,
per_page: 100
});
const existingLabelNames = existingLabels.map(l => l.name);
// Find labels that need to be created
const labelsToCreate = labelsToAdd.filter(label => !existingLabelNames.includes(label));
// Create missing labels (with a default color)
for (const label of labelsToCreate) {
try {
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: 'ededed' // light gray, you can pick any hex color
});
} catch (e) {
// Ignore if label already exists (race condition), otherwise rethrow
if (!e || e.status !== 422) throw e;
}
}
// Now apply all labels (they all exist now)
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: labelsToAdd
});
}

View File

@@ -39,4 +39,4 @@ jobs:
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}

33
.github/workflows/vulncheck.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# 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: Analysis
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24.x
cached: false
- name: Get official govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
shell: bash
- name: Run govulncheck
run: govulncheck -C ./beszel -show verbose ./...
shell: bash

View File

@@ -37,6 +37,8 @@ builds:
- arm
- mips64
- riscv64
- mipsle
- ppc64le
ignore:
- goos: freebsd
goarch: arm
@@ -51,7 +53,7 @@ builds:
archives:
- id: beszel-agent
format: tar.gz
formats: [tar.gz]
builds:
- beszel-agent
name_template: >-
@@ -60,10 +62,10 @@ archives:
{{- .Arch }}
format_overrides:
- goos: windows
format: zip
formats: [zip]
- id: beszel
format: tar.gz
formats: [tar.gz]
builds:
- beszel
name_template: >-
@@ -87,9 +89,6 @@ nfpms:
- beszel-agent
formats:
- deb
# don't think this is needed with CGO_ENABLED=0
# dependencies:
# - libc6
contents:
- src: ../supplemental/debian/beszel-agent.service
dst: lib/systemd/system/beszel-agent.service
@@ -120,8 +119,8 @@ scoops:
repository:
owner: henrygd
name: beszel-scoops
homepage: 'https://beszel.dev'
description: 'Agent for Beszel, a lightweight server monitoring platform.'
homepage: "https://beszel.dev"
description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
@@ -153,9 +152,10 @@ brews:
repository:
owner: henrygd
name: homebrew-beszel
homepage: 'https://beszel.dev'
description: 'Agent for Beszel, a lightweight server monitoring platform.'
homepage: "https://beszel.dev"
description: "Agent for Beszel, a lightweight server monitoring platform."
license: MIT
skip_upload: auto
extra_install: |
(bin/"beszel-agent-launcher").write <<~EOS
#!/bin/bash
@@ -172,6 +172,43 @@ brews:
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: auto
description: |
Beszel is a lightweight server monitoring platform that includes Docker
statistics, historical data, and alert functions. It has a friendly web
interface, simple configuration, and is ready to use out of the box.
It supports automatic backup, multi-user, OAuth authentication, and
API access.
tags:
- homelab
- monitoring
- self-hosted
repository:
owner: henrygd
name: beszel-winget
branch: henrygd.beszel-agent-{{ .Version }}
pull_request:
enabled: true
draft: false
base:
owner: microsoft
name: winget-pkgs
branch: master
release:
draft: true
@@ -181,5 +218,5 @@ changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"

View File

@@ -47,18 +47,18 @@ generate-locales:
dev-server: generate-locales
cd ./site
@if command -v bun >/dev/null 2>&1; then \
cd ./site && bun run dev; \
cd ./site && bun run dev --host 0.0.0.0; \
else \
cd ./site && npm run dev; \
cd ./site && npm run dev --host 0.0.0.0; \
fi
dev-hub: export ENV=dev
dev-hub:
mkdir -p ./site/dist && touch ./site/dist/index.html
@if command -v entr >/dev/null 2>&1; then \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve"; \
find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \
else \
cd ./cmd/hub && go run . serve; \
cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \
fi
dev-agent:

View File

@@ -3,6 +3,7 @@ package main
import (
"beszel"
"beszel/internal/agent"
"beszel/internal/agent/health"
"flag"
"fmt"
"log"
@@ -50,12 +51,7 @@ func (opts *cmdOptions) parse() bool {
agent.Update()
return true
case "health":
// for health, we need to parse flags first to get the listen address
args := append(os.Args[2:], subcommand)
flag.CommandLine.Parse(args)
addr := opts.getAddress()
network := agent.GetNetwork(addr)
err := agent.Health(addr, network)
err := health.Check()
if err != nil {
log.Fatal(err)
}
@@ -115,8 +111,12 @@ func main() {
serverConfig.Addr = addr
serverConfig.Network = agent.GetNetwork(addr)
agent := agent.NewAgent()
if err := agent.StartServer(serverConfig); err != nil {
log.Fatal("Failed to start server:", err)
agent, err := agent.NewAgent("")
if err != nil {
log.Fatal("Failed to create agent: ", err)
}
if err := agent.Start(serverConfig); err != nil {
log.Fatal("Failed to start server: ", err)
}
}

View File

@@ -12,9 +12,15 @@ COPY internal ./internal
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./cmd/agent
RUN rm -rf /tmp/*
# ? -------------------------
FROM scratch
COPY --from=builder /agent /agent
ENTRYPOINT ["/agent"]
# this is so we don't need to create the
# /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
ENTRYPOINT ["/agent"]

View File

@@ -1,24 +1,26 @@
module beszel
go 1.24.2
go 1.24.4
// lock shoutrrr to specific version to allow review before updating
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
require (
github.com/blang/semver v3.5.1+incompatible
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5
github.com/nicholas-fedor/shoutrrr v0.8.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.8.15
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.27.1
github.com/pocketbase/pocketbase v0.29.0
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.3
github.com/spf13/cast v1.7.1
github.com/shirou/gopsutil/v4 v4.25.6
github.com/spf13/cast v1.9.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.37.0
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
golang.org/x/crypto v0.40.0
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
gopkg.in/yaml.v3 v3.0.1
)
@@ -27,21 +29,22 @@ require (
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/ebitengine/purego v0.8.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -49,20 +52,21 @@ require (
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/spf13/pflag v1.0.6 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
modernc.org/libc v1.64.0 // indirect
golang.org/x/image v0.29.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
)

View File

@@ -13,17 +13,21 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
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/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.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
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.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
@@ -42,10 +46,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -68,6 +70,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -77,6 +81,8 @@ 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-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/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=
@@ -97,8 +103,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.27.1 h1:KGCsS8idUVTC5QHxTj91qHDhIXOb5Yb50wwHhNvJRTQ=
github.com/pocketbase/pocketbase v0.27.1/go.mod h1:aTpwwloVJzeJ7MlwTRrbI/x62QNR2/kkCrovmyrXpqs=
github.com/pocketbase/pocketbase v0.29.0 h1:oL6qvkU2QSybClVtQdaq9Z1F3Wk59iKYCfIaf1R8KUs=
github.com/pocketbase/pocketbase v0.29.0/go.mod h1:SqyH7o/3e+/uLySATlJqxH4S8gyU6R0adG56ZSV1vuU=
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=
@@ -108,14 +114,15 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
@@ -129,35 +136,37 @@ github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XV
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/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.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -165,19 +174,19 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w
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.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -193,26 +202,30 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -4,34 +4,55 @@ package agent
import (
"beszel"
"beszel/internal/entities/system"
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gliderlabs/ssh"
"github.com/shirou/gopsutil/v4/host"
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
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
zfs bool // true if system has arcstats
memCalc string // Memory calculation formula
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
netInterfaces map[string]struct{} // Stores all valid network interfaces
netIoStats system.NetIoStats // Keeps track of bandwidth usage
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
connectionManager *ConnectionManager // Channel to signal connection events
server *ssh.Server // SSH server
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
}
func NewAgent() *Agent {
agent := &Agent{
// 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: NewSessionCache(69 * time.Second),
}
agent.dataDir, err = getDataDir(dataDir)
if err != nil {
slog.Warn("Data directory not found")
} else {
slog.Info("Data directory", "path", agent.dataDir)
}
agent.memCalc, _ = GetEnv("MEM_CALC")
agent.sensorConfig = agent.newSensorConfig()
// Set up slog with a log level determined by the LOG_LEVEL env var
@@ -49,10 +70,19 @@ func NewAgent() *Agent {
slog.Debug(beszel.Version)
// initialize system info / docker manager
// initialize system info
agent.initializeSystemInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
// initialize disk info
agent.initializeDiskInfo()
// initialize net io stats
agent.initializeNetIoStats()
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
// initialize GPU manager
@@ -67,7 +97,7 @@ func NewAgent() *Agent {
slog.Debug("Stats", "data", agent.gatherStats(""))
}
return agent
return agent, nil
}
// GetEnv retrieves an environment variable with a "BESZEL_AGENT_" prefix, or falls back to the unprefixed key.
@@ -115,3 +145,38 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.cache.Set(sessionID, cachedData)
return cachedData
}
// StartAgent 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 {
// first look for a fingerprint in the data directory
if a.dataDir != "" {
if fp, err := os.ReadFile(filepath.Join(a.dataDir, "fingerprint")); err == nil {
return string(fp)
}
}
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
if err != nil || fingerprint == "" {
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
}
// hash fingerprint
sum := sha256.Sum256([]byte(fingerprint))
fingerprint = hex.EncodeToString(sum[:24])
// save fingerprint to data directory
if a.dataDir != "" {
err = os.WriteFile(filepath.Join(a.dataDir, "fingerprint"), []byte(fingerprint), 0644)
if err != nil {
slog.Warn("Failed to save fingerprint", "err", err)
}
}
return fingerprint
}

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
package agent
import (

View File

@@ -0,0 +1,9 @@
//go:build testing
// +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
}

View File

@@ -0,0 +1,265 @@
package agent
import (
"beszel"
"beszel/internal/common"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"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 := 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, _ := GetEnv("TOKEN")
if token != "" {
return token, nil
}
// get token from file
tokenFile, _ := 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 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) {
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
}
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
slog.Error("Error parsing message", "err", err)
return
}
if err := client.handleHubRequest(client.hubRequest); 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]) (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.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}
return client.sendMessage(response)
}
// 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.
// It ensures the hub is verified before processing most requests.
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
if !client.hubVerified && msg.Action != common.CheckFingerprint {
return errors.New("hub not verified")
}
switch msg.Action {
case common.GetData:
return client.sendSystemData()
case common.CheckFingerprint:
return client.handleAuthChallenge(msg)
}
return nil
}
// sendSystemData gathers and sends current system statistics to the hub.
func (client *WebSocketClient) sendSystemData() error {
sysStats := client.agent.gatherStats(client.token)
return client.sendMessage(sysStats)
}
// 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
}
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
}
// 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)
}

View File

@@ -0,0 +1,538 @@
//go:build testing
// +build testing
package agent
import (
"beszel"
"beszel/internal/common"
"crypto/ed25519"
"net/url"
"os"
"strings"
"testing"
"time"
"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)
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")
})
}

View File

@@ -0,0 +1,220 @@
package agent
import (
"beszel/internal/agent/health"
"errors"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
)
// 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
}
// 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
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
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 <-sigChan:
slog.Info("Shutting down")
_ = 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.stopWsTicker()
_ = c.agent.StopServer()
c.isConnecting = false
case SSHConnected:
// stop new ws connection attempts
slog.Info("SSH connection established")
c.stopWsTicker()
c.isConnecting = false
case Disconnected:
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()
}
}

View File

@@ -0,0 +1,315 @@
//go:build testing
// +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")
}

View File

@@ -0,0 +1,117 @@
package agent
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
)
// 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, _ := 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
}

View File

@@ -0,0 +1,263 @@
//go:build testing
// +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)
})
}

View File

@@ -2,6 +2,7 @@ package agent
import (
"beszel/internal/entities/container"
"bytes"
"context"
"encoding/json"
"fmt"
@@ -27,6 +28,9 @@ type dockerManager struct {
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
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -63,10 +67,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
return nil, err
}
defer resp.Body.Close()
dm.apiContainerList = dm.apiContainerList[:0]
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
return nil, err
}
@@ -83,7 +86,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
var failedContainers []*container.ApiInfo
for _, ctr := range dm.apiContainerList {
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -111,7 +115,8 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
// 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 _, ctr := range failedContainers {
for i := range failedContainers {
ctr := failedContainers[i]
dm.queue()
go func() {
defer dm.dequeue()
@@ -164,8 +169,13 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
stats.NetworkRecv = 0
// docker host container stats response
var res container.ApiStats
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
// res := dm.getApiStats()
// defer dm.putApiStats(res)
//
res := dm.apiStats
res.Networks = nil
if err := dm.decode(resp, res); err != nil {
return err
}
@@ -173,9 +183,14 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
var usedMemory uint64
var cpuPct float64
// store current cpu stats
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
stats.CpuSystem = res.CPUStats.SystemUsage
if dm.isWindows {
usedMemory = res.MemoryStats.PrivateWorkingSet
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
} else {
// check if container has valid data, otherwise may be in restart loop (#103)
if res.MemoryStats.Usage == 0 {
@@ -187,13 +202,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
}
usedMemory = res.MemoryStats.Usage - memCache
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
}
if cpuPct > 100 {
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
}
stats.PrevCpu = [2]uint64{res.CPUStats.CPUUsage.TotalUsage, res.CPUStats.SystemUsage}
// network
var total_sent, total_recv uint64
@@ -201,21 +215,25 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
total_sent += v.TxBytes
total_recv += v.RxBytes
}
var sent_delta, recv_delta float64
// prevent first run from sending all prev sent/recv bytes
if initialized {
secondsElapsed := time.Since(stats.PrevRead).Seconds()
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
var sent_delta, recv_delta uint64
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if initialized && millisecondsElapsed > 0 {
// get bytes per second
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
// check for unrealistic network values (> 5GB/s)
if sent_delta > 5e9 || recv_delta > 5e9 {
slog.Warn("Bad network delta", "container", name)
sent_delta, recv_delta = 0, 0
}
}
stats.PrevNet.Sent = total_sent
stats.PrevNet.Recv = total_recv
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.NetworkSent = bytesToMegabytes(sent_delta)
stats.NetworkRecv = bytesToMegabytes(recv_delta)
stats.PrevRead = res.Read
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = res.Read
return nil
}
@@ -231,7 +249,6 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
slog.Info("DOCKER_HOST", "host", dockerHost)
// return nil if set to empty string
if dockerHost == "" {
return nil
@@ -242,7 +259,6 @@ func newDockerManager(a *Agent) *dockerManager {
parsedURL, err := url.Parse(dockerHost)
if err != nil {
slog.Error("Error parsing DOCKER_HOST", "err", err)
os.Exit(1)
}
@@ -290,6 +306,7 @@ func newDockerManager(a *Agent) *dockerManager {
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
}
// If using podman, return client
@@ -308,9 +325,8 @@ func newDockerManager(a *Agent) *dockerManager {
if err != nil {
return manager
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
if err := manager.decode(resp, &versionInfo); err != nil {
return manager
}
@@ -324,6 +340,22 @@ func newDockerManager(a *Agent) *dockerManager {
return manager
}
// 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://"

View File

@@ -18,24 +18,24 @@ import (
const (
// Commands
nvidiaSmiCmd = "nvidia-smi"
rocmSmiCmd = "rocm-smi"
tegraStatsCmd = "tegrastats"
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
tegraStatsCmd string = "tegrastats"
// Polling intervals
nvidiaSmiInterval = "4" // in seconds
tegraStatsInterval = "3700" // in milliseconds
rocmSmiInterval = 4300 * time.Millisecond
nvidiaSmiInterval string = "4" // in seconds
tegraStatsInterval string = "3700" // in milliseconds
rocmSmiInterval time.Duration = 4300 * time.Millisecond
// Command retry and timeout constants
retryWaitTime = 5 * time.Second
maxFailureRetries = 5
retryWaitTime time.Duration = 5 * time.Second
maxFailureRetries int = 5
cmdBufferSize = 10 * 1024
cmdBufferSize uint16 = 10 * 1024
// Unit Conversions
mebibytesInAMegabyte = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt = 1000.0 // tegrastats reports power in mW
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)
@@ -243,21 +243,26 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// copy / reset the data
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
for id, gpu := range gm.GpuDataMap {
// sum the data
gpu.Temperature = twoDecimals(gpu.Temperature)
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
// reset the count
gpu.Count = 1
// dereference to avoid overwriting anything else
gpuCopy := *gpu
gpuAvg := *gpu
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
// avoid division by zero
if gpu.Count > 0 {
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
}
// reset accumulators in the original
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
// append id to the name if there are multiple GPUs with the same name
if nameCounts[gpu.Name] > 1 {
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
}
gpuData[id] = gpuCopy
gpuData[id] = gpuAvg
}
slog.Debug("GPU", "data", gpuData)
return gpuData

View File

@@ -279,6 +279,19 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "orin nano",
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 3452.0,
MemoryTotal: 7620.0,
Usage: 0.0,
Temperature: 50.25,
Power: 0.518,
Count: 1,
},
},
{
name: "missing temperature",
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
@@ -318,44 +331,85 @@ func TestParseJetsonData(t *testing.T) {
}
func TestGetCurrentData(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "GPU1",
Temperature: 50,
MemoryUsed: 2048,
MemoryTotal: 4096,
Usage: 100, // 100 over 2 counts = 50 avg
Power: 200, // 200 over 2 counts = 100 avg
Count: 2,
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
},
"2": {
Name: "GPU 2",
Temperature: 70,
MemoryUsed: 4096,
MemoryTotal: 8192,
Usage: 200,
Power: 400,
Count: 1,
},
},
"1": {
Name: "GPU1",
Temperature: 60,
MemoryUsed: 3072,
MemoryTotal: 8192,
Usage: 30,
Power: 60,
Count: 1,
}
result := gm.GetCurrentData()
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
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 reset
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
})
t.Run("handles zero count without panicking", func(t *testing.T) {
gm := &GPUManager{
GpuDataMap: map[string]*system.GPUData{
"0": {
Name: "TestGPU",
Count: 0,
Usage: 0,
Power: 0,
},
},
},
}
}
result := gm.GetCurrentData()
var result map[string]system.GPUData
assert.NotPanics(t, func() {
result = gm.GetCurrentData()
})
// Verify name disambiguation
assert.Equal(t, "GPU1 0", result["0"].Name)
assert.Equal(t, "GPU1 1", result["1"].Name)
// Check that usage and power are 0
assert.Equal(t, 0.0, result["0"].Usage)
assert.Equal(t, 0.0, result["0"].Power)
// Check averaged values
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
// Verify reset counts
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
// Verify reset count
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
})
}
func TestDetectGPUs(t *testing.T) {
@@ -722,6 +776,18 @@ func TestAccumulation(t *testing.T) {
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
}
// Verify that accumulators in the original map are reset
for id := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
if !exists {
continue
}
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
}
})
}
}

View File

@@ -1,18 +0,0 @@
package agent
import (
"net"
"time"
)
// Health checks if the agent's server is running by attempting to connect to it.
//
// If an error occurs when attempting to connect to the server, it returns the error.
func Health(addr string, network string) error {
conn, err := net.DialTimeout(network, addr, 4*time.Second)
if err != nil {
return err
}
conn.Close()
return nil
}

View File

@@ -0,0 +1,43 @@
// 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"
"time"
)
// healthFile is the path to the health file
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
// 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 {
file, err := os.Create(healthFile)
if err != nil {
return err
}
return file.Close()
}
// CleanUp removes the health file
func CleanUp() error {
return os.Remove(healthFile)
}

View File

@@ -0,0 +1,67 @@
//go:build testing
// +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.
// NOTE: This test requires GOEXPERIMENT=synctest to run.
t.Run("check with simulated time", func(t *testing.T) {
synctest.Run(func() {
// 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")
})
})
}

View File

@@ -1,118 +0,0 @@
//go:build testing
// +build testing
package agent_test
import (
"fmt"
"net"
"os"
"testing"
"github.com/stretchr/testify/require"
"beszel/internal/agent"
)
// setupTestServer creates a temporary server for testing
func setupTestServer(t *testing.T) (string, func()) {
// Create a temporary socket file for Unix socket testing
tempSockFile := os.TempDir() + "/beszel_health_test.sock"
// Clean up any existing socket file
os.Remove(tempSockFile)
// Create a listener
listener, err := net.Listen("unix", tempSockFile)
require.NoError(t, err, "Failed to create test listener")
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the socket file path and a cleanup function
return tempSockFile, func() {
listener.Close()
os.Remove(tempSockFile)
}
}
// setupTCPTestServer creates a temporary TCP server for testing
func setupTCPTestServer(t *testing.T) (string, func()) {
// Listen on a random available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "Failed to create test listener")
// Get the port that was assigned
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the address and a cleanup function
return fmt.Sprintf("127.0.0.1:%d", port), func() {
listener.Close()
}
}
func TestHealth(t *testing.T) {
t.Run("server is running (unix socket)", func(t *testing.T) {
// Setup a test server
sockFile, cleanup := setupTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(sockFile, "unix")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is running (tcp address)", func(t *testing.T) {
// Setup a test server
addr, cleanup := setupTCPTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is not running", func(t *testing.T) {
// Use an address that's likely not in use
addr := "127.0.0.1:65535"
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.Error(t, err, "Health check should return an error when server is not running")
})
t.Run("invalid network", func(t *testing.T) {
// Use an invalid network type
err := agent.Health("127.0.0.1:8080", "invalid_network")
require.Error(t, err, "Health check should return an error with invalid network")
})
t.Run("unix socket not found", func(t *testing.T) {
// Use a non-existent unix socket
nonExistentSocket := os.TempDir() + "/non_existent_socket.sock"
// Make sure it really doesn't exist
os.Remove(nonExistentSocket)
err := agent.Health(nonExistentSocket, "unix")
require.Error(t, err, "Health check should return an error when socket doesn't exist")
})
}

View File

@@ -57,6 +57,7 @@ func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
strings.HasPrefix(v.Name, "docker"),
strings.HasPrefix(v.Name, "br-"),
strings.HasPrefix(v.Name, "veth"),
strings.HasPrefix(v.Name, "bond"),
v.BytesRecv == 0,
v.BytesSent == 0:
return true

View File

@@ -3,10 +3,13 @@ package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"unicode/utf8"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
@@ -30,6 +33,9 @@ func (a *Agent) newSensorConfig() *SensorConfig {
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 {
@@ -78,8 +84,18 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// reset high temp
a.systemInfo.DashboardTemp = 0
// get sensor data
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
// retry once on panic (gopsutil/issues/1832)
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
if err != nil {
slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 {
systemStats.Temperatures = make(map[string]float64)
}
return
}
}
slog.Debug("Temperature", "sensors", temps)
// return if no sensors
@@ -89,6 +105,15 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
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
@@ -103,15 +128,28 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
continue
}
// set dashboard temperature
if a.sensorConfig.primarySensor == "" {
switch a.sensorConfig.primarySensor {
case "":
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
} else if a.sensorConfig.primarySensor == sensorName {
case sensorName:
a.systemInfo.DashboardTemp = sensor.Temperature
}
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
}
}
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
// get sensor data (error ignored intentionally as it may be only with one sensor)
temps, _ = getTemps(a.sensorConfig.context)
return
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid
@@ -141,3 +179,19 @@ func isValidSensor(sensorName string, config *SensorConfig) bool {
return config.isBlacklist
}
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
func scaleTemperature(temp float64) float64 {
if temp > 1 {
return temp
}
scaled100 := temp * 100
scaled1000 := temp * 1000
if scaled100 >= 15 && scaled100 <= 95 {
return scaled100
} else if scaled1000 >= 15 && scaled1000 <= 95 {
return scaled1000
}
return scaled100
}

View File

@@ -4,11 +4,14 @@
package agent
import (
"beszel/internal/entities/system"
"context"
"fmt"
"os"
"testing"
"github.com/shirou/gopsutil/v4/common"
"github.com/shirou/gopsutil/v4/sensors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -372,3 +375,179 @@ func TestNewSensorConfig(t *testing.T) {
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
assert.Equal(t, "/test/path", sysPath)
}
func TestScaleTemperature(t *testing.T) {
tests := []struct {
name string
input float64
expected float64
desc string
}{
// Normal temperatures (no scaling needed)
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
// Zero temperature
{"zero_temp", 0.0, 0.0, "Zero temperature"},
// Fractional values that should use 100x scaling
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
// Fractional values that should use 1000x scaling
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
// Edge cases - values outside reasonable range
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
// Boundary cases around the reasonable range (15-95°C)
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
// Values just outside reasonable range
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scaleTemperature(tt.input)
assert.InDelta(t, tt.expected, result, 0.001,
"scaleTemperature(%v) = %v, expected %v (%s)",
tt.input, result, tt.expected, tt.desc)
})
}
}
func TestScaleTemperatureLogic(t *testing.T) {
// Test the logic flow for ambiguous cases
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
// 0.5 could be 50°C (100x) or 500°C (1000x)
// Should prefer 100x since it's tried first and is in range
result := scaleTemperature(0.5)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
result, expected)
})
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
// Should use 1000x since 100x is below reasonable range
result := scaleTemperature(0.05)
expected := 50.0
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
result, expected)
})
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
// Should default to 100x scaling
result := scaleTemperature(0.005)
expected := 0.5
assert.InDelta(t, expected, result, 0.001,
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
result, expected)
})
}
func TestGetTempsWithPanicRecovery(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
},
}
tests := []struct {
name string
getTempsFn getTempsFn
expectError bool
errorMsg string
}{
{
name: "successful_function_call",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, nil
},
expectError: false,
},
{
name: "function_returns_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "test_sensor", Temperature: 45.0},
}, fmt.Errorf("sensor error")
},
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
},
{
name: "function_panics_with_string",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic("test panic")
},
expectError: true,
errorMsg: "panic: test panic",
},
{
name: "function_panics_with_error",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
panic(fmt.Errorf("panic error"))
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_index_out_of_bounds",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
slice := []int{1, 2, 3}
_ = slice[10] // out of bounds panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
{
name: "function_panics_with_any_conversion",
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
var i any = "string"
_ = i.(int) // type assertion panic
return nil, nil
},
expectError: true,
errorMsg: "panic:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var temps []sensors.TemperatureStat
var err error
// The function should not panic, regardless of what the injected function does
assert.NotPanics(t, func() {
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
}, "getTempsWithPanicRecovery should not panic")
if tt.expectError {
assert.Error(t, err, "Expected an error to be returned")
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg,
"Error message should contain expected text")
}
assert.Nil(t, temps, "Temps should be nil when panic occurs")
} else {
assert.NoError(t, err, "Should not return error for successful calls")
}
})
}
}

View File

@@ -1,25 +1,43 @@
package agent
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"time"
sshServer "github.com/gliderlabs/ssh"
"golang.org/x/crypto/ssh"
"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 string
Keys []ssh.PublicKey
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 {
sshServer.Handle(a.handleSession)
if a.server != nil {
return errors.New("server already started")
}
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
@@ -37,33 +55,109 @@ func (a *Agent) StartServer(opts ServerOptions) error {
}
defer ln.Close()
// Start SSH server on the listener
return sshServer.Serve(ln, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{
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 sshServer.KeysEqual(key, pubKey) {
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)
}
func (a *Agent) handleSession(s sshServer.Session) {
slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil {
// 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)
stats := a.gatherStats(sessionID)
err := a.writeToSession(s, stats, hubVersion)
if err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
} else {
s.Exit(0)
}
s.Exit(0)
}
// 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) ([]ssh.PublicKey, error) {
var parsedKeys []ssh.PublicKey
func ParseKeys(input string) ([]gossh.PublicKey, error) {
var parsedKeys []gossh.PublicKey
for line := range strings.Lines(input) {
line = strings.TrimSpace(line)
// Skip empty lines or comments
@@ -71,7 +165,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
continue
}
// Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
parsedKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
}
@@ -80,7 +174,9 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
return parsedKeys, nil
}
// GetAddress gets the address to listen on or connect to from environment variables or default value.
// 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, _ = GetEnv("LISTEN")
@@ -99,7 +195,9 @@ func GetAddress(addr string) string {
return addr
}
// GetNetwork returns the network type to use based on the address
// 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 := GetEnv("NETWORK"); ok && network != "" {
return network
@@ -109,3 +207,17 @@ func GetNetwork(addr string) string {
}
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
}

View File

@@ -1,34 +1,43 @@
package agent
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
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 := ssh.NewSignerFromKey(privKey)
signer, err := gossh.NewSignerFromKey(privKey)
require.NoError(t, err)
sshPubKey, err := ssh.NewPublicKey(pubKey)
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 := ssh.NewSignerFromKey(badPrivKey)
badSigner, err := gossh.NewSignerFromKey(badPrivKey)
require.NoError(t, err)
sshBadPubKey, err := ssh.NewPublicKey(badPubKey)
sshBadPubKey, err := gossh.NewPublicKey(badPubKey)
require.NoError(t, err)
socketFile := filepath.Join(t.TempDir(), "beszel-test.sock")
@@ -46,7 +55,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -54,7 +63,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp4",
Addr: "127.0.0.1:45988",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -62,7 +71,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp6",
Addr: "[::1]:45989",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
{
@@ -70,7 +79,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "unix",
Addr: socketFile,
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
setup: func() error {
// Create a socket file that should be removed
@@ -89,7 +98,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshBadPubKey},
Keys: []gossh.PublicKey{sshBadPubKey},
},
wantErr: true,
errContains: "ssh: handshake failed",
@@ -99,7 +108,7 @@ func TestStartServer(t *testing.T) {
config: ServerOptions{
Network: "tcp",
Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey},
Keys: []gossh.PublicKey{sshPubKey},
},
},
}
@@ -115,7 +124,8 @@ func TestStartServer(t *testing.T) {
defer tt.cleanup()
}
agent := NewAgent()
agent, err := NewAgent("")
require.NoError(t, err)
// Start server in a goroutine since it blocks
errChan := make(chan error, 1)
@@ -127,8 +137,7 @@ func TestStartServer(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Try to connect to verify server is running
var client *ssh.Client
var err error
var client *gossh.Client
// Choose the appropriate signer based on the test case
testSigner := signer
@@ -136,23 +145,23 @@ func TestStartServer(t *testing.T) {
testSigner = badSigner
}
sshClientConfig := &ssh.ClientConfig{
sshClientConfig := &gossh.ClientConfig{
User: "a",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(testSigner),
Auth: []gossh.AuthMethod{
gossh.PublicKeys(testSigner),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
switch tt.config.Network {
case "unix":
client, err = ssh.Dial("unix", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("unix", tt.config.Addr, sshClientConfig)
default:
if !strings.Contains(tt.config.Addr, ":") {
tt.config.Addr = ":" + tt.config.Addr
}
client, err = ssh.Dial("tcp", tt.config.Addr, sshClientConfig)
client, err = gossh.Dial("tcp", tt.config.Addr, sshClientConfig)
}
if tt.wantErr {
@@ -287,3 +296,310 @@ func TestParseInvalidKey(t *testing.T) {
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.Info.Hostname, decodedCbor.Info.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.Info.Hostname, decodedJson.Info.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,
},
Info: system.Info{
Hostname: "test-host",
Cores: 8,
CpuModel: "Test CPU Model",
Uptime: 3600,
AgentVersion: "0.12.0",
Os: system.Linux,
},
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())
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
psutilNet "github.com/shirou/gopsutil/v4/net"
)
@@ -77,6 +78,17 @@ func (a *Agent) getSystemStats() system.Stats {
systemStats.Cpu = twoDecimals(cpuPct[0])
}
// load average
if avgstat, err := load.Avg(); err == nil {
// TODO: remove these in future release in favor of load avg array
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
@@ -163,24 +175,27 @@ func (a *Agent) getSystemStats() system.Stats {
a.initializeNetIoStats()
}
if netIO, err := psutilNet.IOCounters(true); err == nil {
secondsElapsed := time.Since(a.netIoStats.Time).Seconds()
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
a.netIoStats.Time = time.Now()
bytesSent := uint64(0)
bytesRecv := uint64(0)
totalBytesSent := uint64(0)
totalBytesRecv := uint64(0)
// sum all bytes sent and received
for _, v := range netIO {
// skip if not in valid network interfaces list
if _, exists := a.netInterfaces[v.Name]; !exists {
continue
}
bytesSent += v.BytesSent
bytesRecv += v.BytesRecv
totalBytesSent += v.BytesSent
totalBytesRecv += v.BytesRecv
}
// add to systemStats
sentPerSecond := float64(bytesSent-a.netIoStats.BytesSent) / secondsElapsed
recvPerSecond := float64(bytesRecv-a.netIoStats.BytesRecv) / secondsElapsed
networkSentPs := bytesToMegabytes(sentPerSecond)
networkRecvPs := bytesToMegabytes(recvPerSecond)
var bytesSentPerSecond, bytesRecvPerSecond uint64
if msElapsed > 0 {
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
}
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
// add check for issue (#150) where sent is a massive number
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
@@ -195,9 +210,10 @@ func (a *Agent) getSystemStats() system.Stats {
} else {
systemStats.NetworkSent = networkSentPs
systemStats.NetworkRecv = networkRecvPs
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
// update netIoStats
a.netIoStats.BytesSent = bytesSent
a.netIoStats.BytesRecv = bytesRecv
a.netIoStats.BytesSent = totalBytesSent
a.netIoStats.BytesRecv = totalBytesRecv
}
}
@@ -240,10 +256,17 @@ func (a *Agent) getSystemStats() system.Stats {
// update base system info
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats

View File

@@ -15,8 +15,13 @@ import (
"github.com/pocketbase/pocketbase/tools/mailer"
)
type hubLike interface {
core.App
MakeLink(parts ...string) string
}
type AlertManager struct {
app core.App
hub hubLike
alertQueue chan alertTask
stopChan chan struct{}
pendingAlerts sync.Map
@@ -42,6 +47,7 @@ type SystemAlertStats struct {
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
}
type SystemAlertData struct {
@@ -79,19 +85,27 @@ var supportsTitle = map[string]struct{}{
}
// NewAlertManager creates a new AlertManager instance.
func NewAlertManager(app core.App) *AlertManager {
func NewAlertManager(app hubLike) *AlertManager {
am := &AlertManager{
app: app,
hub: app,
alertQueue: make(chan alertTask),
stopChan: make(chan struct{}),
}
am.bindEvents()
go am.startWorker()
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)
}
// SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error {
// get user settings
record, err := am.app.FindFirstRecordByFilter(
record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}",
dbx.Params{"user": data.UserID},
)
@@ -104,12 +118,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Webhooks: []string{},
}
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
}
// send alerts via webhooks
for _, webhook := range userAlertSettings.Webhooks {
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
}
}
// send alerts via email
@@ -125,15 +139,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
Subject: data.Title,
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
From: mail.Address{
Address: am.app.Settings().Meta.SenderAddress,
Name: am.app.Settings().Meta.SenderName,
Address: am.hub.Settings().Meta.SenderAddress,
Name: am.hub.Settings().Meta.SenderName,
},
}
err = am.app.NewMailClient().Send(&message)
err = am.hub.NewMailClient().Send(&message)
if err != nil {
return err
}
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
return nil
}
@@ -183,9 +197,9 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
err = shoutrrr.Send(parsedURL.String(), message)
if err == nil {
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
} else {
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
return err
}
return nil
@@ -201,7 +215,7 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
if url == "" {
return e.JSON(200, map[string]string{"err": "URL is required"})
}
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
if err != nil {
return e.JSON(200, map[string]string{"err": err.Error()})
}

View File

@@ -0,0 +1,85 @@
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)
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)
return e.Next()
}
// resolveAlertHistoryRecord sets the resolved field to the current time
func resolveAlertHistoryRecord(app core.App, alertRecord *core.Record) error {
alertHistoryRecords, err := app.FindRecordsByFilter(
"alerts_history",
"alert_id={:alert_id} && resolved=null",
"-created",
1,
0,
dbx.Params{"alert_id": alertRecord.Id},
)
if err != nil {
return err
}
if len(alertHistoryRecords) == 0 {
return nil
}
alertHistoryRecord := alertHistoryRecords[0] // there should be only one record
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
}

View File

@@ -2,7 +2,6 @@ package alerts
import (
"fmt"
"net/url"
"strings"
"time"
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
"system": systemID,
"name": "Status",
})
@@ -130,13 +129,21 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
}
// No alert scheduled for this record, send "up" alert
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
am.app.Logger().Error("Failed to send alert", "err", err.Error())
am.hub.Logger().Error("Failed to send alert", "err", err)
}
}
}
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
switch alertStatus {
case "up":
alertRecord.Set("triggered", false)
case "down":
alertRecord.Set("triggered", true)
}
am.hub.Save(alertRecord)
var emoji string
if alertStatus == "up" {
emoji = "\u2705" // Green checkmark emoji
@@ -147,19 +154,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
return errs["user"]
}
user := alertRecord.ExpandedOne("user")
if user == nil {
return nil
}
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// return errs["user"]
// }
// user := alertRecord.ExpandedOne("user")
// if user == nil {
// return nil
// }
return am.SendAlert(AlertMessageData{
UserID: user.Id,
UserID: alertRecord.GetString("user"),
Title: title,
Message: message,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -2,12 +2,11 @@ package alerts
import (
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
@@ -15,8 +14,8 @@ import (
)
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.app.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
alertRecords, err := am.hub.FindAllRecords("alerts",
dbx.NewExp("system={:system} AND name!='Status'", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
// log.Println("no alerts found for system")
@@ -55,6 +54,15 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
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 = ""
}
triggered := alertRecord.GetBool("triggered")
@@ -101,7 +109,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
Created types.DateTime `db:"created"`
}{}
err = am.app.DB().
err = am.hub.DB().
Select("stats", "created").
From("system_stats").
Where(dbx.NewExp(
@@ -191,6 +199,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
}
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]
default:
continue
}
@@ -248,6 +262,10 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
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
titleAlertName := alert.name
@@ -271,12 +289,12 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.app.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err.Error())
if err := am.hub.Save(alert.alertRecord); err != nil {
// app.Logger().Error("failed to save alert record", "err", err)
return
}
// expand the user relation and send the alert
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
// app.Logger().Error("failed to expand user relation", "errs", errs)
return
}
@@ -285,7 +303,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
UserID: user.Id,
Title: subject,
Message: body,
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -0,0 +1,10 @@
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"}
)

View File

@@ -0,0 +1,32 @@
package common
type WebSocketAction = uint8
// Not implemented yet
// type AgentError = uint8
const (
// Request system data from agent
GetData WebSocketAction = iota
// Check the fingerprint of the agent
CheckFingerprint
)
// 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"`
// Error AgentError `cbor:"error,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,omitempty,omitzero"`
Port string `cbor:"2,keyasint,omitempty,omitzero"`
}

View File

@@ -34,10 +34,16 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
return float64(cpuDelta) / float64(systemDelta) * 100
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
@@ -99,12 +105,14 @@ type prevNetStats struct {
// Docker container stats
type Stats struct {
Name string `json:"n"`
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
PrevCpu [2]uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevRead time.Time `json:"-"`
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" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
// PrevCpu [2]uint64 `json:"-"`
CpuSystem uint64 `json:"-"`
CpuContainer uint64 `json:"-"`
PrevNet prevNetStats `json:"-"`
PrevReadTime time.Time `json:"-"`
}

View File

@@ -8,38 +8,45 @@ import (
)
type Stats struct {
Cpu float64 `json:"cpu"`
MaxCpu float64 `json:"cpum,omitempty"`
Mem float64 `json:"m"`
MemUsed float64 `json:"mu"`
MemPct float64 `json:"mp"`
MemBuffCache float64 `json:"mb"`
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
Swap float64 `json:"s,omitempty"`
SwapUsed float64 `json:"su,omitempty"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskPct float64 `json:"dp"`
DiskReadPs float64 `json:"dr"`
DiskWritePs float64 `json:"dw"`
MaxDiskReadPs float64 `json:"drm,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
MaxNetworkSent float64 `json:"nsm,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty"`
Temperatures map[string]float64 `json:"t,omitempty"`
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
GPUData map[string]GPUData `json:"g,omitempty"`
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"1,keyasint,omitempty"`
Mem float64 `json:"m" cbor:"2,keyasint"`
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" cbor:"12,keyasint"`
DiskWritePs float64 `json:"dw" cbor:"13,keyasint"`
MaxDiskReadPs float64 `json:"drm,omitempty" cbor:"14,keyasint,omitempty"`
MaxDiskWritePs float64 `json:"dwm,omitempty" cbor:"15,keyasint,omitempty"`
NetworkSent float64 `json:"ns" cbor:"16,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
MaxNetworkSent float64 `json:"nsm,omitempty" cbor:"18,keyasint,omitempty"`
MaxNetworkRecv float64 `json:"nrm,omitempty" cbor:"19,keyasint,omitempty"`
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:"27,keyasint,omitzero"` // [sent bytes, recv bytes]
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
// TODO: remove other load fields in future release in favor of load avg array
}
type GPUData struct {
Name string `json:"n"`
Name string `json:"n" cbor:"0,keyasint"`
Temperature float64 `json:"-"`
MemoryUsed float64 `json:"mu,omitempty"`
MemoryTotal float64 `json:"mt,omitempty"`
Usage float64 `json:"u"`
Power float64 `json:"p,omitempty"`
MemoryUsed float64 `json:"mu,omitempty" cbor:"1,keyasint,omitempty"`
MemoryTotal float64 `json:"mt,omitempty" cbor:"2,keyasint,omitempty"`
Usage float64 `json:"u" cbor:"3,keyasint"`
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
Count float64 `json:"-"`
}
@@ -47,14 +54,14 @@ type FsStats struct {
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
DiskTotal float64 `json:"d"`
DiskUsed float64 `json:"du"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"-"`
TotalWrite uint64 `json:"-"`
DiskReadPs float64 `json:"r"`
DiskWritePs float64 `json:"w"`
MaxDiskReadPS float64 `json:"rm,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty"`
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
}
type NetIoStats struct {
@@ -64,7 +71,7 @@ type NetIoStats struct {
Name string
}
type Os uint8
type Os = uint8
const (
Linux Os = iota
@@ -74,26 +81,32 @@ const (
)
type Info struct {
Hostname string `json:"h"`
KernelVersion string `json:"k,omitempty"`
Cores int `json:"c"`
Threads int `json:"t,omitempty"`
CpuModel string `json:"m"`
Uptime uint64 `json:"u"`
Cpu float64 `json:"cpu"`
MemPct float64 `json:"mp"`
DiskPct float64 `json:"dp"`
Bandwidth float64 `json:"b"`
AgentVersion string `json:"v"`
Podman bool `json:"p,omitempty"`
GpuPct float64 `json:"g,omitempty"`
DashboardTemp float64 `json:"dt,omitempty"`
Os Os `json:"os"`
Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
Cores int `json:"c" cbor:"2,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m" cbor:"4,keyasint"`
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" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
// TODO: remove load fields in future release in favor of load avg array
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats"`
Info Info `json:"info"`
Containers []*container.Stats `json:"container"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
}

View File

@@ -0,0 +1,320 @@
package hub
import (
"beszel/internal/common"
"beszel/internal/hub/expirymap"
"beszel/internal/hub/ws"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"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)
// 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)
// 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(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"
}
// create new record
systemRecord := core.NewRecord(systemsCollection)
systemRecord.Set("name", agentFingerprint.Hostname)
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 diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
package hub
// Package config provides functions for syncing systems with the config.yml file
package config
import (
"beszel/internal/entities/system"
@@ -7,6 +8,7 @@ import (
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -14,19 +16,20 @@ import (
"gopkg.in/yaml.v3"
)
type Config struct {
Systems []SystemConfig `yaml:"systems"`
type config struct {
Systems []systemConfig `yaml:"systems"`
}
type SystemConfig struct {
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 syncSystemsWithConfig(e *core.ServeEvent) error {
func SyncSystems(e *core.ServeEvent) error {
h := e.App
configPath := filepath.Join(h.DataDir(), "config.yml")
configData, err := os.ReadFile(configPath)
@@ -34,7 +37,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
return nil
}
var config Config
var config config
err = yaml.Unmarshal(configData, &config)
if err != nil {
return fmt.Errorf("failed to parse config.yml: %v", err)
@@ -107,6 +110,14 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
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
@@ -124,10 +135,21 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
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
// 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
@@ -139,7 +161,7 @@ func syncSystemsWithConfig(e *core.ServeEvent) error {
}
// Generates content for the config.yml file as a YAML string
func (h *Hub) generateConfigYAML() (string, error) {
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 {
@@ -147,8 +169,8 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Create a Config struct to hold the data
config := Config{
Systems: make([]SystemConfig, 0, len(systems)),
config := config{
Systems: make([]systemConfig, 0, len(systems)),
}
// Fetch all users at once
@@ -156,11 +178,29 @@ func (h *Hub) generateConfigYAML() (string, error) {
for _, system := range systems {
allUserIDs = append(allUserIDs, system.GetStringSlice("users")...)
}
userEmailMap, err := h.getUserEmailMap(allUserIDs)
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")
@@ -171,11 +211,12 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
}
sysConfig := SystemConfig{
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)
}
@@ -187,13 +228,13 @@ func (h *Hub) generateConfigYAML() (string, error) {
}
// Add a header to the YAML
yamlData = append([]byte("# Values for port and users are optional.\n# Defaults are port 45876 and the first created user.\n\n"), yamlData...)
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 (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
func getUserEmailMap(h core.App, userIDs []string) (map[string]string, error) {
users, err := h.FindRecordsByIds("users", userIDs)
if err != nil {
return nil, err
@@ -207,13 +248,42 @@ func (h *Hub) getUserEmailMap(userIDs []string) (map[string]string, error) {
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 (h *Hub) getYamlConfig(e *core.RequestEvent) error {
func GetYamlConfig(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
return apis.NewForbiddenError("Forbidden", nil)
}
configContent, err := h.generateConfigYAML()
configContent, err := generateYAML(e.App)
if err != nil {
return err
}

View File

@@ -0,0 +1,245 @@
//go:build testing
// +build testing
package config_test
import (
"beszel/internal/hub/config"
"beszel/internal/tests"
"os"
"path/filepath"
"testing"
"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"))
}

View File

@@ -0,0 +1,104 @@
package expirymap
import (
"reflect"
"time"
"github.com/pocketbase/pocketbase/tools/store"
)
type val[T any] struct {
value T
expires time.Time
}
type ExpiryMap[T any] struct {
store *store.Store[string, *val[T]]
cleanupInterval time.Duration
}
// New creates a new expiry map with custom cleanup interval
func New[T any](cleanupInterval time.Duration) *ExpiryMap[T] {
m := &ExpiryMap[T]{
store: store.New(map[string]*val[T]{}),
cleanupInterval: cleanupInterval,
}
m.startCleaner()
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 reflect.DeepEqual(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 reflect.DeepEqual(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() {
go func() {
tick := time.Tick(m.cleanupInterval)
for range tick {
m.cleanup()
}
}()
}
// 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)
}
}
}

View File

@@ -0,0 +1,477 @@
//go:build testing
// +build testing
package expirymap
import (
"testing"
"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_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)
// Try to remove the expired value - should remove one of the "value1" entries
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"))
// Should have removed one of the "value1" entries (either key1 or key3)
// But we can't predict which one due to map iteration order
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
// Exactly one of key1 or key3 should be gone
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should still exist
}
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)
}

View File

@@ -4,19 +4,24 @@ package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/hub/config"
"beszel/internal/hub/systems"
"beszel/internal/records"
"beszel/internal/users"
"beszel/site"
"crypto/ed25519"
"encoding/pem"
"fmt"
"io/fs"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -30,6 +35,7 @@ type Hub struct {
rm *records.RecordManager
sm *systems.SystemManager
pubKey string
signer ssh.Signer
appURL string
}
@@ -56,14 +62,13 @@ func GetEnv(key string) (value string, exists bool) {
}
func (h *Hub) StartHub() error {
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
// initialize settings / collections
if err := h.initialize(e); err != nil {
return err
}
// sync systems with config
if err := syncSystemsWithConfig(e); err != nil {
if err := config.SyncSystems(e); err != nil {
return err
}
// register api routes
@@ -111,6 +116,9 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
if err := e.App.Save(settings); err != nil {
return err
}
// set auth settings
usersCollection, err := e.App.FindCollectionByNameOrId("users")
if err != nil {
@@ -156,7 +164,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
return nil
}
// startServer starts the server for the Beszel (not PocketBase)
// startServer sets up the server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
// TODO: exclude dev server from production binary
switch h.IsDev() {
@@ -180,6 +188,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
indexContent = strings.Replace(indexContent, "{{HUB_URL}}", h.appURL, 1)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
@@ -206,7 +215,7 @@ func (h *Hub) startServer(se *core.ServeEvent) error {
// registerCronJobs sets up scheduled tasks
func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
// delete old records once every hour
// 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)
@@ -231,7 +240,11 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
// API endpoint to get config.yml content
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
// get or create universal tokens
se.Router.GET("/api/beszel/universal-token", h.getUniversalToken)
// create first user endpoint only needed if no users exist
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
@@ -239,73 +252,100 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
return nil
}
// generates key pair if it doesn't exist and returns private key bytes
func (h *Hub) GetSSHKey() ([]byte, error) {
dataDir := h.DataDir()
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
// Handler for universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
info, err := e.RequestInfo()
if err != nil || info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
tokenMap := universalTokenMap.GetMap()
userID := info.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
tokenSet := token != ""
if !tokenSet {
// 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})
}
// return existing private key
return existingKey, nil
// if no token is provided, generate a new one
token = uuid.New().String()
}
response := map[string]any{"token": token}
switch query.Get("enable") {
case "1":
tokenMap.Set(token, userID, time.Hour)
case "0":
tokenMap.RemovebyValue(userID)
}
_, response["active"] = tokenMap.GetOk(token)
return e.JSON(http.StatusOK, response)
}
// 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
pubKey, privKey, err := ed25519.GenerateKey(nil)
_, privKey, err := ed25519.GenerateKey(nil)
if err != nil {
// h.Logger().Error("Error generating key pair:", "err", err.Error())
return nil, err
}
// Get the private key in OpenSSH format
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
return nil, err
}
// Save the private key to a file
privateFile, err := os.Create(dataDir + "/id_ed25519")
if err != nil {
// h.Logger().Error("Error creating private key file:", "err", err.Error())
return nil, err
}
defer privateFile.Close()
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
return nil, err
}
// Generate the public key in OpenSSH format
publicKey, err := ssh.NewPublicKey(pubKey)
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
if err != nil {
return nil, err
}
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
}
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPrivate.PublicKey())
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
// Save the public key to a file
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
if err != nil {
return nil, err
}
defer publicFile.Close()
h.Logger().Info("ed25519 key pair generated successfully.")
h.Logger().Info("Saved to: " + privateKeyPath)
if _, err := publicFile.Write(pubKeyBytes); err != nil {
return nil, err
}
h.Logger().Info("ed25519 SSH key pair generated successfully.")
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
if err == nil {
return existingKey, nil
}
return nil, err
return sshPrivate, err
}
// MakeLink formats a link with the app URL and path segments.
// Only path segments should be provided.
func (h *Hub) MakeLink(parts ...string) string {
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
for _, part := range parts {
if part == "" {
continue
}
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
}
return base
}

View File

@@ -0,0 +1,256 @@
//go:build testing
// +build testing
package hub_test
import (
"beszel/internal/tests"
"testing"
"crypto/ed25519"
"encoding/pem"
"os"
"path/filepath"
"strings"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
func getTestHub(t testing.TB) *tests.TestHub {
hub, _ := tests.NewTestHub(t.TempDir())
return hub
}
func TestMakeLink(t *testing.T) {
hub := getTestHub(t)
tests := []struct {
name string
appURL string
parts []string
expected string
}{
{
name: "no parts, no trailing slash in AppURL",
appURL: "http://localhost:8090",
parts: []string{},
expected: "http://localhost:8090",
},
{
name: "no parts, with trailing slash in AppURL",
appURL: "http://localhost:8090/",
parts: []string{},
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
},
{
name: "one part",
appURL: "http://example.com",
parts: []string{"one"},
expected: "http://example.com/one",
},
{
name: "multiple parts",
appURL: "http://example.com",
parts: []string{"alpha", "beta", "gamma"},
expected: "http://example.com/alpha/beta/gamma",
},
{
name: "parts with spaces needing escaping",
appURL: "http://example.com",
parts: []string{"path with spaces", "another part"},
expected: "http://example.com/path%20with%20spaces/another%20part",
},
{
name: "parts with slashes needing escaping",
appURL: "http://example.com",
parts: []string{"a/b", "c"},
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
},
{
name: "AppURL with subpath, no trailing slash",
appURL: "http://localhost/sub",
parts: []string{"resource"},
expected: "http://localhost/sub/resource",
},
{
name: "AppURL with subpath, with trailing slash",
appURL: "http://localhost/sub/",
parts: []string{"item"},
expected: "http://localhost/sub/item",
},
{
name: "empty parts in the middle",
appURL: "http://localhost",
parts: []string{"first", "", "third"},
expected: "http://localhost/first/third",
},
{
name: "leading and trailing empty parts",
appURL: "http://localhost",
parts: []string{"", "path", ""},
expected: "http://localhost/path",
},
{
name: "parts with various special characters",
appURL: "https://test.dev/",
parts: []string{"p@th?", "key=value&"},
expected: "https://test.dev/p@th%3F/key=value&",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Store original app URL and restore it after the test
originalAppURL := hub.Settings().Meta.AppURL
hub.Settings().Meta.AppURL = tt.appURL
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
got := hub.MakeLink(tt.parts...)
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
})
}
}
func TestGetSSHKey(t *testing.T) {
hub := getTestHub(t)
// 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")
})
}
})
}

View File

@@ -0,0 +1,21 @@
//go:build testing
// +build testing
package hub
import "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
}

View File

@@ -0,0 +1,387 @@
package systems
import (
"beszel"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"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
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
}
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
}
data, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords(data)
}
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
// add system_stats and container_stats records
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(data.Containers) > 0 {
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStatsCollection)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, 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 := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// 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
}
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsData, err := sys.fetchDataViaWebSocket()
if err == nil {
return wsData, nil
}
// close the WebSocket connection if error and try SSH
sys.closeWebSocketConnection()
}
sshData, err := sys.fetchDataViaSSH()
if err != nil {
return nil, err
}
return sshData, nil
}
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
err := sys.WsConn.RequestSystemData(sys.data)
if err != nil {
return nil, err
}
return sys.data, nil
}
// 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() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.closeSSHConnection()
// Reset format detection on connection failure - agent might have been upgraded
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
*sys.data = system.CombinedData{}
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
err = cbor.NewDecoder(stdout).Decode(sys.data)
} else {
err = json.NewDecoder(stdout).Decode(sys.data)
}
if err != nil {
sys.closeSSHConnection()
if attempt < maxRetries {
continue
}
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
// 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.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 40% and 90% of the interval.
// This is used to stagger the initial WebSocket connections to prevent clustering.
func getJitter() <-chan time.Time {
minPercent := 40
maxPercent := 90
jitterRange := maxPercent - minPercent
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
return time.After(time.Duration(msDelay) * time.Millisecond)
}

View File

@@ -0,0 +1,346 @@
package systems
import (
"beszel"
"beszel/internal/common"
"beszel/internal/entities/system"
"beszel/internal/hub/ws"
"errors"
"fmt"
"time"
"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
)
var (
// errSystemExists is returned when attempting to add a system that already exists
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
}
// 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,
}
}
// 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)
}
// 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
}

View File

@@ -1,435 +0,0 @@
package systems
import (
"beszel/internal/entities/system"
"context"
"fmt"
"net"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
const (
up string = "up"
down string = "down"
paused string = "paused"
pending string = "pending"
interval int = 60_000
sessionTimeout = 4 * time.Second
)
type SystemManager struct {
hub hubLike
systems *store.Store[string, *System]
sshConfig *ssh.ClientConfig
}
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager
client *ssh.Client
data *system.CombinedData
ctx context.Context
cancel context.CancelFunc
}
type hubLike interface {
core.App
GetSSHKey() ([]byte, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize initializes the system manager.
// It binds the event hooks and starts updating existing systems.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// ssh setup
key, err := sm.hub.GetSSHKey()
if err != nil {
return err
}
if err := sm.createSSHClientConfig(key); err != nil {
return err
}
// start updating existing systems
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
go func() {
// time between initial system updates
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
}
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)
}
// Runs before the record is committed to the database
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// Runs after the record is committed to the database
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// Runs before the record is updated
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// Runs after the record is updated
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
switch newStatus {
case paused:
sm.RemoveSystem(e.Record.Id)
return e.Next()
case pending:
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
system, ok := sm.systems.GetOk(e.Record.Id)
if !ok {
return sm.AddRecord(e.Record)
}
prevStatus := system.Status
system.Status = newStatus
// system alerts if system is up
if system.Status == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
if (system.Status == down && prevStatus == up) || (system.Status == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(system.Status, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// Runs after the record is deleted
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return fmt.Errorf("system exists")
}
if sys.Id == "" || sys.Host == "" {
return fmt.Errorf("system is missing required fields")
}
sys.manager = sm
sys.ctx, sys.cancel = context.WithCancel(context.Background())
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return fmt.Errorf("system not found")
}
// cancel the context to signal stop
if system.cancel != nil {
system.cancel()
}
system.resetSSHClient()
sm.systems.Remove(systemID)
return nil
}
// AddRecord adds a record to the system manager.
// It first removes any existing system with the same ID, then creates a new System
// instance from the record data and adds it to the manager.
// This function is typically called when a new system is created or when an existing
// system's status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record) (err error) {
_ = sm.RemoveSystem(record.Id)
system := &System{
Id: record.Id,
Status: record.GetString("status"),
Host: record.GetString("host"),
Port: record.GetString("port"),
}
return sm.AddSystem(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() {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
c := time.Tick(time.Duration(interval) * time.Millisecond)
for {
select {
case <-sys.ctx.Done():
return
case <-c:
err := sys.update()
if err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
// It first fetches the data from the agent then updates the records.
func (sys *System) update() error {
_, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords()
}
return err
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords() (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", sys.data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(sys.data.Containers) > 0 {
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", sys.data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, 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", sys.data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found or the system is paused, 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 {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", OriginalError)
record.Set("status", down)
err = sys.manager.hub.SaveNoValidate(record)
if err != nil {
return err
}
return nil
}
// fetchDataFromAgent fetches the data from the agent.
// It first creates a new SSH client if it doesn't exist or the system is down.
// Then it creates a new SSH session and fetches the data from the agent.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.resetSSHClient()
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
// this is initialized in startUpdater, should never be nil
*sys.data = system.CombinedData{}
if err := json.NewDecoder(stdout).Decode(sys.data); err != nil {
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout,
}
return nil
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
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
}
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")
}
}
// resetSSHClient closes the SSH connection and resets the client to nil
func (sys *System) resetSSHClient() {
if sys.client != nil {
sys.client.Close()
}
sys.client = nil
}

View File

@@ -11,70 +11,133 @@ import (
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestSystem creates a test system record with a unique host name
// and returns the created record and any error
func createTestSystem(t *testing.T, hub *tests.TestHub, options map[string]any) (*core.Record, error) {
collection, err := hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return nil, err
}
// get user record
var firstUser *core.Record
users, err := hub.FindAllRecords("users", dbx.NewExp("id != ''"))
if err != nil {
t.Fatal(err)
}
if len(users) > 0 {
firstUser = users[0]
}
// Generate a unique host name to ensure we're adding a new system
uniqueHost := fmt.Sprintf("test-host-%d.example.com", time.Now().UnixNano())
// Create the record
record := core.NewRecord(collection)
record.Set("name", uniqueHost)
record.Set("host", uniqueHost)
record.Set("port", "45876")
record.Set("status", "pending")
record.Set("users", []string{firstUser.Id})
// Apply any custom options
for key, value := range options {
record.Set(key, value)
}
// Save the record to the database
err = hub.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
func TestSystemManagerIntegration(t *testing.T) {
// Create a test hub
hub, err := tests.NewTestHub()
func TestSystemManagerNew(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer hub.Cleanup()
sm := hub.GetSystemManager()
// Create independent system manager
sm := systems.NewSystemManager(hub)
user, err := tests.CreateUser(hub, "test@test.com", "testtesttest")
require.NoError(t, err)
synctest.Run(func() {
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)
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)
// Test initialization
sm.Initialize()
// 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) {
@@ -92,81 +155,17 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.NotNil(t, containerStats)
})
// Test adding a system record
t.Run("AddRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// record should be pending on create
hub.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "pending", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// record should be down on update
hub.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "down", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// Create a test system with the first user assigned
record, err := createTestSystem(t, hub, map[string]any{
"name": "welcometoarcoampm",
"host": "localhost",
"port": "33914",
})
require.NoError(t, err)
wg.Wait()
// system should be down if grabbed from the store
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// Check that the system count increased
countAfter := sm.GetSystemCount()
assert.Equal(t, countBefore+1, countAfter, "System count should increase after adding a system via event hook")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.Get("host"), host, "System host should match")
assert.Equal(t, record.Get("port"), port, "System port should match")
// Verify the system is in the list of all system IDs
ids := sm.GetAllSystemIDs()
assert.Contains(t, ids, record.Id, "System ID should be in the list of all system IDs")
// Verify the system was added by checking if removing it works
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err, "System should exist and be removable")
// 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
newIds := sm.GetAllSystemIDs()
assert.NotContains(t, newIds, record.Id, "System ID should not be in the list of all system IDs after removal")
})
t.Run("RemoveSystem", func(t *testing.T) {
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
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
@@ -202,11 +201,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("NewRecordPending", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
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)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test filtering records by status - should be "pending" now
@@ -218,11 +222,16 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("SystemStatusUpdate", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
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)
err = sm.AddRecord(record, nil)
require.NoError(t, err)
// Test status changes
@@ -244,7 +253,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("HandleSystemData", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
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
@@ -295,54 +309,14 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err)
})
t.Run("DeleteRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
runs := 0
hub.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
runs++
record := e.Record
if record.GetString("name") == "deadflagblues" {
if runs == 1 {
assert.Equal(t, "up", e.Record.GetString("status"), "System status should be 'up'")
wg.Done()
} else if runs == 2 {
assert.Equal(t, "paused", e.Record.GetString("status"), "System status should be 'paused'")
wg.Done()
}
}
return e.Next()
})
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{
"name": "deadflagblues",
})
require.NoError(t, err)
// Verify the system exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// set the status manually to up
sm.SetSystemStatusInDB(record.Id, "up")
// verify the status is up
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// Set the status to "paused" which should cause it to be deleted from the store
sm.SetSystemStatusInDB(record.Id, "paused")
wg.Wait()
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
})
t.Run("ConcurrentOperations", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
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
@@ -377,7 +351,12 @@ func TestSystemManagerIntegration(t *testing.T) {
t.Run("ContextCancellation", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
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
@@ -420,7 +399,7 @@ func TestSystemManagerIntegration(t *testing.T) {
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
// Add the system back
err = sm.AddRecord(record)
err = sm.AddRecord(record, nil)
require.NoError(t, err, "AddRecord should succeed")
// Verify the system is back in the store

View File

@@ -9,17 +9,17 @@ import (
"fmt"
)
// GetSystemCount returns the number of systems in the store
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()
}
// HasSystem checks if a system with the given ID exists in the store
// 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)
}
// GetSystemStatusFromStore returns the status of a system with the given ID
// 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)
@@ -29,7 +29,7 @@ func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
return sys.Status
}
// GetSystemContextFromStore returns the context and cancel function for a system
// 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 {
@@ -38,7 +38,7 @@ func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Con
return sys.ctx, sys.cancel, nil
}
// GetSystemFromStore returns a store from the system
// 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 {
@@ -47,7 +47,7 @@ func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
return sys, nil
}
// GetAllSystemIDs returns a slice of all system IDs in the store
// 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))
@@ -57,7 +57,7 @@ func (sm *SystemManager) GetAllSystemIDs() []string {
return ids
}
// GetSystemData returns the combined data for a system with the given ID
// 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 {
@@ -68,7 +68,7 @@ func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
return sys.data
}
// GetSystemHostPort returns the host and port for a system with the given ID
// 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)
@@ -78,22 +78,7 @@ func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
return sys.Host, sys.Port
}
// DisableAutoUpdater disables the automatic updater for a system
// This is intended for testing
// Returns false if the system doesn't exist
// func (sm *SystemManager) DisableAutoUpdater(systemID string) bool {
// sys, ok := sm.systems.GetOk(systemID)
// if !ok {
// return false
// }
// if sys.cancel != nil {
// sys.cancel()
// sys.cancel = nil
// }
// return true
// }
// SetSystemStatusInDB sets the status of a system directly and updates the database record
// 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 {

View File

@@ -0,0 +1,180 @@
package ws
import (
"beszel/internal/common"
"beszel/internal/entities/system"
"errors"
"time"
"weak"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
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
responseChan chan *gws.Message
DownChan chan struct{}
}
// 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.
func NewWsConnection(conn *gws.Conn) *WsConn {
return &WsConn{
conn: conn,
responseChan: make(chan *gws.Message, 1),
DownChan: make(chan struct{}, 1),
}
}
// OnOpen sets a deadline for the WebSocket connection.
func (h *Handler) OnOpen(conn *gws.Conn) {
conn.SetDeadline(time.Now().Add(deadline))
}
// OnMessage routes incoming WebSocket messages to the response channel.
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
}
select {
case wsConn.(*WsConn).responseChan <- message:
default:
// close if the connection is not expecting a response
wsConn.(*WsConn).Close(nil)
}
}
// 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)
}
}
// 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.
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)
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
var message *gws.Message
ws.sendMessage(common.HubRequest[any]{
Action: common.GetData,
})
select {
case <-time.After(10 * time.Second):
ws.Close(nil)
return gws.ErrConnClosed
case message = <-ws.responseChan:
}
defer message.Close()
return cbor.Unmarshal(message.Data.Bytes(), data)
}
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
var clientFingerprint common.FingerprintResponse
challenge := []byte(token)
signature, err := signer.Sign(nil, challenge)
if err != nil {
return clientFingerprint, err
}
err = ws.sendMessage(common.HubRequest[any]{
Action: common.CheckFingerprint,
Data: common.FingerprintRequest{
Signature: signature.Blob,
NeedSysInfo: needSysInfo,
},
})
if err != nil {
return clientFingerprint, err
}
var message *gws.Message
select {
case message = <-ws.responseChan:
case <-time.After(10 * time.Second):
return clientFingerprint, errors.New("request expired")
}
defer message.Close()
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
return clientFingerprint, err
}
// IsConnected returns true if the WebSocket connection is active.
func (ws *WsConn) IsConnected() bool {
return ws.conn != nil
}

View File

@@ -0,0 +1,221 @@
//go:build testing
// +build testing
package ws
import (
"beszel/internal/common"
"crypto/ed25519"
"testing"
"time"
"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)
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.responseChan, "Response channel should be initialized")
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
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)
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)
// 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)
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")
}
// 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)
// 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")
}
// Response channel should be empty initially
select {
case <-wsConn.responseChan:
t.Error("Response channel should be empty initially")
default:
// Expected - channel should be empty
}
}

View File

@@ -4,13 +4,13 @@ package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"encoding/json"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
@@ -26,14 +26,26 @@ type LongerRecordData struct {
minShorterRecords int
}
type RecordStats []struct {
Stats []byte `db:"stats"`
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
var containerStats []container.Stats
var sumStats system.Stats
var tempStats system.Stats
var queryParams = make(dbx.Params, 1)
var containerSums = make(map[string]*container.Stats)
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
@@ -76,11 +88,10 @@ func (rm *RecordManager) CreateLongerRecords() {
if err != nil {
return err
}
var systems []struct {
Id string `db:"id"`
}
var systems RecordIds
db := txApp.DB()
txApp.DB().NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
db.NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
// loop through all active systems, time periods, and collections
for _, system := range systems {
@@ -96,22 +107,23 @@ func (rm *RecordManager) CreateLongerRecords() {
for _, collection := range collections {
// check creation time of last longer record if not 10m, since 10m is created every run
if recordData.longerType != "10m" {
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
count, err := txApp.CountRecords(
collection.Id,
"system = {:system} && type = {:type} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
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 || lastLongerRecord != nil {
// log.Println("longer record found. continuing")
if err != nil || count > 0 {
continue
}
}
// get shorter records from the past x minutes
var stats RecordStats
var recordIds RecordIds
err := txApp.DB().
Select("stats").
Select("id").
From(collection.Name).
AndWhere(dbx.NewExp(
"system={:system} AND type={:type} AND created > {:created}",
@@ -121,10 +133,10 @@ func (rm *RecordManager) CreateLongerRecords() {
"created": shorterRecordPeriod,
},
)).
All(&stats)
All(&recordIds)
// continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords {
if err != nil || len(recordIds) < recordData.minShorterRecords {
continue
}
// average the shorter records and create longer record
@@ -133,9 +145,10 @@ func (rm *RecordManager) CreateLongerRecords() {
longerRecord.Set("type", recordData.longerType)
switch collection.Name {
case "system_stats":
longerRecord.Set("stats", rm.AverageSystemStats(stats))
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
case "container_stats":
longerRecord.Set("stats", rm.AverageContainerStats(stats))
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err)
@@ -147,24 +160,34 @@ func (rm *RecordManager) CreateLongerRecords() {
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(records RecordStats) *system.Stats {
sum := &system.Stats{}
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
count := float64(len(records))
tempCount := float64(0)
// Temporary struct for unmarshaling
stats := &system.Stats{}
// Accumulate totals
for i := range records {
*stats = system.Stats{} // Reset tempStats for unmarshaling
if err := json.Unmarshal(records[i].Stats, stats); err != nil {
for _, record := range records {
id := record.Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
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
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
@@ -180,12 +203,19 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
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]
// Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
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])
// Accumulate temperatures
if stats.Temperatures != nil {
@@ -255,7 +285,11 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / 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)
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {
@@ -293,14 +327,24 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
sums := make(map[string]*container.Stats)
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))
containerStats := make([]container.Stats, 0, 50)
for i := range records {
// reset slice
id := records[i].Id
// clear global statsRecord and containerStats for reuse
statsRecord.Stats = statsRecord.Stats[:0]
containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != 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 {
@@ -328,12 +372,46 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
return result
}
// Deletes records older than what is displayed in the UI
// Delete old records
func (rm *RecordManager) DeleteOldRecords() {
// Define the collections to process
collections := []string{"system_stats", "container_stats"}
rm.app.RunInTransaction(func(txApp core.App) error {
err := deleteOldSystemStats(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
}
return nil
})
}
// Define record types and their retention periods
// 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
@@ -346,31 +424,28 @@ func (rm *RecordManager) DeleteOldRecords() {
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
}
// Process each collection
now := time.Now().UTC()
for _, collection := range collections {
// Build the WHERE clause dynamically
// Build the WHERE clause
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i, rd := range recordData {
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] = time.Now().UTC().Add(-rd.retention)
params[dateParam] = now.Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct the full raw query
// Construct and execute the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
// Execute the query with parameters
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
return fmt.Errorf("failed to delete from %s: %v", collection, err)
}
}
return nil
}
/* Round float to two decimals */

View File

@@ -0,0 +1,381 @@
//go:build testing
// +build testing
package records_test
import (
"beszel/internal/records"
"beszel/internal/tests"
"fmt"
"testing"
"time"
"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.TestDeleteOldSystemStats(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.TestDeleteOldAlertsHistory(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.TestDeleteOldAlertsHistory(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.TestDeleteOldAlertsHistory(hub, 10, 20)
require.NoError(t, err)
})
}
// 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.TestTwoDecimals(tc.input)
assert.InDelta(t, tc.expected, result, 0.02, "twoDecimals(%f) should equal %f", tc.input, tc.expected)
}
}

View File

@@ -0,0 +1,23 @@
//go:build testing
// +build testing
package records
import (
"github.com/pocketbase/pocketbase/core"
)
// TestDeleteOldSystemStats exposes deleteOldSystemStats for testing
func TestDeleteOldSystemStats(app core.App) error {
return deleteOldSystemStats(app)
}
// TestDeleteOldAlertsHistory exposes deleteOldAlertsHistory for testing
func TestDeleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
return deleteOldAlertsHistory(app, countToKeep, countBeforeDeletion)
}
// TestTwoDecimals exposes twoDecimals for testing
func TestTwoDecimals(value float64) float64 {
return twoDecimals(value)
}

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
// Package tests provides helpers for testing the application.
package tests
@@ -56,3 +59,30 @@ func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) {
return t, nil
}
// Helper function to create a test user for config tests
func CreateUser(app core.App, email string, password string) (*core.Record, error) {
userCollection, err := app.FindCachedCollectionByNameOrId("users")
if err != nil {
return nil, err
}
user := core.NewRecord(userCollection)
user.Set("email", email)
user.Set("password", password)
return user, app.Save(user)
}
// Helper function to create a test record
func CreateRecord(app core.App, collectionName string, fields map[string]any) (*core.Record, error) {
collection, err := app.FindCachedCollectionByNameOrId(collectionName)
if err != nil {
return nil, err
}
record := core.NewRecord(collection)
record.Load(fields)
return record, app.Save(record)
}

View File

@@ -17,7 +17,12 @@ type UserSettings struct {
ChartTime string `json:"chartTime"`
NotificationEmails []string `json:"emails"`
NotificationWebhooks []string `json:"webhooks"`
// Language string `json:"lang"`
// UnitTemp uint8 `json:"unitTemp"` // 0 for Celsius, 1 for Fahrenheit
// UnitNet uint8 `json:"unitNet"` // 0 for bytes, 1 for bits
// UnitDisk uint8 `json:"unitDisk"` // 0 for bytes, 1 for bits
// New field for alert history retention (e.g., "1m", "3m", "6m", "1y")
AlertHistoryRetention string `json:"alertHistoryRetention,omitempty"`
}
func NewUserManager(app core.App) *UserManager {
@@ -39,7 +44,6 @@ func (um *UserManager) InitializeUserSettings(e *core.RecordEvent) error {
record := e.Record
// intialize settings with defaults
settings := UserSettings{
// Language: "en",
ChartTime: "1h",
NotificationEmails: []string{},
NotificationWebhooks: []string{},

View File

@@ -1,23 +1,14 @@
package migrations
import (
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// delete duplicate alerts
app.DB().NewQuery(`
DELETE FROM alerts
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM alerts
GROUP BY user, system, name
);
`).Execute()
// import collections
// update collections
jsonData := `[
{
"id": "elngm8x1l60zi2v",
@@ -84,7 +75,10 @@ func init() {
"Memory",
"Disk",
"Temperature",
"Bandwidth"
"Bandwidth",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
]
},
{
@@ -146,6 +140,124 @@ func init() {
],
"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 != \"\"",
@@ -236,6 +348,88 @@ func init() {
],
"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 != \"\"",
@@ -669,7 +863,37 @@ func init() {
}
]`
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
if err != nil {
return err
}
// Get all systems that don't have fingerprint records
var systemIds []string
err = app.DB().NewQuery(`
SELECT s.id FROM systems s
LEFT JOIN fingerprints f ON s.id = f.system
WHERE f.system IS NULL
`).Column(&systemIds)
if err != nil {
return err
}
// Create fingerprint records with unique UUID tokens for each system
for _, systemId := range systemIds {
token := uuid.New().String()
_, err = app.DB().NewQuery(`
INSERT INTO fingerprints (system, token)
VALUES ({:system}, {:token})
`).Bind(map[string]any{
"system": systemId,
"token": token,
}).Execute()
if err != nil {
return err
}
}
return nil
}, func(app core.App) error {
return nil
})

View File

@@ -5,7 +5,7 @@ import (
m "github.com/pocketbase/pocketbase/migrations"
)
var (
const (
TempAdminEmail = "_@b.b"
)

Binary file not shown.

View File

@@ -4,12 +4,13 @@
<meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" />
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Beszel</title>
<script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}"
HUB_VERSION: "{{V}}",
HUB_URL: "{{HUB_URL}}"
}
</script>
</head>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.11.1",
"version": "0.12.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,54 +13,55 @@
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
"@lingui/detect-locale": "^5.2.0",
"@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"@lingui/detect-locale": "^5.3.3",
"@lingui/macro": "^5.3.3",
"@lingui/react": "^5.3.3",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-direction": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-table": "^8.21.2",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.25.2",
"pocketbase": "^0.26.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1",
"recharts": "^2.15.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.42.0"
"valibot": "^0.42.1"
},
"devDependencies": {
"@lingui/cli": "^5.2.0",
"@lingui/swc-plugin": "^5.5.0",
"@lingui/vite-plugin": "^5.2.0",
"@types/bun": "^1.2.4",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"@lingui/cli": "^5.3.3",
"@lingui/swc-plugin": "^5.5.2",
"@lingui/vite-plugin": "^5.3.3",
"@tailwindcss/container-queries": "^0.1.1",
"@types/bun": "^1.2.19",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.2",
"vite": "^6.2.0"
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"overrides": {
"@nanostores/router": {

View File

@@ -11,20 +11,27 @@ import {
DialogTrigger,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import { i18n } from "@lingui/core"
import { cn, generateToken, isReadOnlyUser, tokenMap, useLocalStorage } from "@/lib/utils"
import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import { basePath, navigate } from "./router"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { $router, basePath, Link, navigate } from "./router"
import { SystemRecord } from "@/types"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
import { InputCopy } from "./ui/input-copy"
import { getPagePath } from "@nanostores/router"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "./install-dropdowns"
import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
export function AddSystemButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
@@ -51,44 +58,11 @@ export function AddSystemButton({ className }: { className?: string }) {
)
}
function copyDockerCompose(port = "45876", publicKey: string) {
copyToClipboard(`services:
beszel-agent:
image: "henrygd/beszel-agent"
container_name: "beszel-agent"
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
LISTEN: ${port}
KEY: "${publicKey}"`)
}
function copyDockerRun(port = "45876", publicKey: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e LISTEN=${port} henrygd/beszel-agent:latest`
)
}
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
let cmd = `curl -sL https://get.beszel.dev${
brew ? "/brew" : ""
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
// brew script does not support --china-mirrors
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
}
function copyWindowsCommand(port = "45876", publicKey: string) {
copyToClipboard(
`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
)
}
/**
* 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.
@@ -96,12 +70,32 @@ function copyWindowsCommand(port = "45876", publicKey: string) {
* @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 = memo(({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) => void; system?: SystemRecord }) => {
const publicKey = useStore($publicKey)
const port = useRef<HTMLInputElement>(null)
const [hostValue, setHostValue] = useState(system?.host ?? "")
const isUnixSocket = hostValue.startsWith("/")
const [tab, setTab] = useLocalStorage("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])
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -113,12 +107,18 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
if (system) {
await pb.collection("systems").update(system.id, { ...data, status: "pending" })
} else {
await pb.collection("systems").create(data)
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)
// console.log(record)
} catch (e) {
console.log(e)
console.error(e)
}
}
@@ -143,18 +143,37 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</DialogHeader>
{/* Docker (set tab index to prevent auto focusing content in edit system dialog) */}
<TabsContent value="docker" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> for the agent below.
Copy the
<code className="bg-muted px-1 rounded-sm leading-3">docker-compose.yml</code> content for the agent
below, or register agents automatically with a{" "}
<Link
onClick={() => setOpen(false)}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
{/* Binary */}
<TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<DialogDescription className="mb-3 leading-relaxed w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below.
Copy the installation command for the agent below, or register agents automatically with a{" "}
<Link
onClick={() => {
setOpen(false)
}}
href={getPagePath($router, "settings", { name: "tokens" })}
className="link"
>
universal token
</Link>
.
</Trans>
</DialogDescription>
</TabsContent>
@@ -190,46 +209,27 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
<Label htmlFor="pkey" className="xs:text-end whitespace-pre">
<Trans comment="Use 'Key' if your language requires many more characters">Public Key</Trans>
</Label>
<div className="relative">
<Input readOnly id="pkey" value={publicKey} required></Input>
<div
className={
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
}
></div>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={"link"}
className="absolute end-0 top-0"
onClick={() => copyToClipboard(publicKey)}
>
<Copy className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<InputCopy value={publicKey} id="pkey" name="pkey" />
<Label htmlFor="tkn" className="xs:text-end whitespace-pre">
<Trans>Token</Trans>
</Label>
<InputCopy value={token} id="tkn" name="tkn" />
</div>
<DialogFooter className="flex justify-end gap-x-2 gap-y-3 flex-col mt-5">
{/* Docker */}
<TabsContent value="docker" className="contents">
<CopyButton
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
onClick={async () =>
copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey, token)
}
icon={<DockerIcon className="size-4 -me-0.5" />}
dropdownItems={[
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<DockerIcon className="size-4" />],
onClick: async () =>
copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [DockerIcon],
},
]}
/>
@@ -239,22 +239,24 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
<CopyButton
text={t`Copy Linux command`}
icon={<TuxIcon className="size-4" />}
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
onClick={async () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token)}
dropdownItems={[
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
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: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
icons: [<WindowsIcon className="size-4" />],
onClick: async () =>
copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, token),
icons: [WindowsIcon],
},
{
text: t`Manual setup instructions`,
url: "https://beszel.dev/guide/agent-installation#binary",
icons: [<ExternalLinkIcon className="size-4" />],
icons: [ExternalLinkIcon],
},
]}
/>
@@ -266,20 +268,13 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</Tabs>
</DialogContent>
)
})
interface DropdownItem {
text: string
onClick?: () => void
url?: string
icons?: React.ReactNode[]
}
interface CopyButtonProps {
text: string
onClick: () => void
dropdownItems: DropdownItem[]
icon?: React.ReactNode
icon?: React.ReactElement
}
const CopyButton = memo((props: CopyButtonProps) => {
@@ -300,26 +295,7 @@ const CopyButton = memo((props: CopyButtonProps) => {
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{props.dropdownItems.map((item, index) => (
<DropdownMenuItem key={index} asChild={!!item.url}>
{item.url ? (
<a
href={item.url}
className="cursor-pointer flex items-center gap-1.5"
target="_blank"
rel="noopener noreferrer"
>
{item.text} {item.icons?.map((icon) => icon)}
</a>
) : (
<div onClick={item.onClick} className="cursor-pointer flex items-center gap-1.5">
{item.text} {item.icons?.map((icon) => icon)}
</div>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
<InstallDropdown items={props.dropdownItems} />
</DropdownMenu>
</div>
)

View File

@@ -0,0 +1,164 @@
import { ColumnDef } from "@tanstack/react-table"
import { AlertsHistoryRecord } from "@/types"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { alertInfo, formatShortDate, toFixedFloat, formatDuration, cn } from "@/lib/utils"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
export const alertsHistoryColumns: ColumnDef<AlertsHistoryRecord>[] = [
{
accessorKey: "system",
enableSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>System</Trans>
</Button>
),
cell: ({ row }) => <span className="ps-2">{row.original.expand?.system?.name || row.original.system}</span>,
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 }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Name</Trans>
</Button>
),
cell: ({ getValue, row }) => {
let name = getValue() as string
const info = alertInfo[row.original.name]
const Icon = info?.icon
return (
<span className="flex items-center gap-2 ps-1 min-w-40">
{Icon && <Icon className="size-3.5" />}
{name}
</span>
)
},
},
{
accessorKey: "value",
enableSorting: false,
header: () => (
<Button variant="ghost">
<Trans>Value</Trans>
</Button>
),
cell({ row, getValue }) {
const name = row.original.name
if (name === "Status") {
return <span className="ps-2">{t`Down`}</span>
}
const value = getValue() as number
const unit = alertInfo[name]?.unit
return (
<span className="tabular-nums ps-2.5">
{toFixedFloat(value, value < 10 ? 2 : 1)}
{unit}
</span>
)
},
},
{
accessorKey: "state",
enableSorting: true,
sortingFn: (rowA, rowB) => (rowA.original.resolved ? 1 : 0) - (rowB.original.resolved ? 1 : 0),
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans comment="Context: alert state (active or resolved)">State</Trans>
</Button>
),
cell: ({ row }) => {
const resolved = row.original.resolved
return (
<Badge
className={cn(
"capitalize pointer-events-none",
resolved
? "bg-green-100 text-green-800 border-green-200 dark:opacity-80"
: "bg-yellow-100 text-yellow-800 border-yellow-200"
)}
>
{/* {resolved ? <CircleCheckIcon className="size-3 me-0.5" /> : <CircleAlertIcon className="size-3 me-0.5" />} */}
{resolved ? <Trans>Resolved</Trans> : <Trans>Active</Trans>}
</Badge>
)
},
},
{
accessorKey: "created",
accessorFn: (record) => formatShortDate(record.created),
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans comment="Context: date created">Created</Trans>
</Button>
),
cell: ({ getValue, row }) => (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.created} UTC`}>
{getValue() as string}
</span>
),
},
{
accessorKey: "resolved",
enableSorting: true,
invertSorting: true,
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Resolved</Trans>
</Button>
),
cell: ({ row, getValue }) => {
const resolved = getValue() as string | null
if (!resolved) {
return null
}
return (
<span className="ps-1 tabular-nums tracking-tight" title={`${row.original.resolved} UTC`}>
{formatShortDate(resolved)}
</span>
)
},
},
{
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 }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
<Trans>Duration</Trans>
</Button>
),
cell: ({ row }) => {
const duration = formatDuration(row.original.created, row.original.resolved)
if (!duration) {
return null
}
return <span className="ps-2">{duration}</span>
},
},
]

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react"
import { $alerts } from "@/lib/stores"
@@ -15,10 +15,11 @@ import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { alertInfo, cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { AlertRecord, SystemRecord } from "@/types"
import { Link } from "../router"
import { $router, Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { getPagePath } from "@nanostores/router"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
@@ -38,13 +39,33 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
/>
</Button>
</DialogTrigger>
<DialogContent className="max-h-full overflow-auto max-w-[35rem]">
<DialogContent className="max-h-full sm:max-h-[95svh] overflow-auto max-w-[37rem]">
{opened && <AlertDialogContent system={system} />}
</DialogContent>
</Dialog>
),
[opened, hasAlert]
)
// return useMemo(
// () => (
// <Sheet>
// <SheetTrigger asChild>
// <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
// <BellIcon
// className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
// "fill-primary": hasAlert,
// })}
// />
// </Button>
// </SheetTrigger>
// <SheetContent className="max-h-full overflow-auto w-[35em] p-4 sm:p-5">
// {opened && <AlertDialogContent system={system} />}
// </SheetContent>
// </Sheet>
// ),
// [opened, hasAlert]
// )
})
function AlertDialogContent({ system }: { system: SystemRecord }) {
@@ -81,7 +102,7 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
<DialogDescription>
<Trans>
See{" "}
<Link href="/settings/notifications" className="link">
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.

View File

@@ -217,7 +217,7 @@ function AlertContent({ data }: { data: AlertData }) {
const [checked, setChecked] = useState(data.checked || false)
const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
const Icon = alertInfo[name].icon
@@ -268,7 +268,8 @@ function AlertContent({ data }: { data: AlertData }) {
onValueChange={(val) => {
setValue(val[0])
}}
min={1}
step={data.alert.step ?? 1}
min={data.alert.min ?? 1}
max={alertInfo[name].max ?? 99}
/>
</div>

View File

@@ -1,150 +1,91 @@
import { t } from "@lingui/core/macro"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
// import Spinner from '../spinner'
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
import { useLingui } from "@lingui/react/macro"
import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils"
import { ChartData, SystemStatsRecord } from "@/types"
import { useMemo } from "react"
/** [label, key, color, opacity] */
type DataKeys = [string, string, number, number]
const getNestedValue = (path: string, max = false, data: any): number | null => {
// fallback value (obj?.stats?.cpum ? 0 : null) should only come into play when viewing
// a max value which doesn't exist, or the value was zero and omitted from the stats object.
// so we check if cpum is present. if so, return 0 to make sure the zero value is displayed.
// if not, return null - there is no max data so do not display anything.
return `stats.${path}${max ? "m" : ""}`
.split(".")
.reduce((acc: any, key: string) => acc?.[key] ?? (data.stats?.cpum ? 0 : null), data)
export type DataPoint = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
color: string
opacity: number
}
export default memo(function AreaChartDefault({
maxToggled = false,
unit = " MB/s",
chartName,
export default function AreaChartDefault({
chartData,
max,
maxToggled,
tickFormatter,
contentFormatter,
}: {
maxToggled?: boolean
unit?: string
chartName: string
dataPoints,
}: // logRender = false,
{
chartData: ChartData
max?: number
tickFormatter?: (value: number) => string
contentFormatter?: (value: number) => string
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
// logRender?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui()
const { chartTime } = chartData
const showMax = chartTime !== "1h" && maxToggled
const dataKeys: DataKeys[] = useMemo(() => {
// [label, key, color, opacity]
if (chartName === "CPU Usage") {
return [[t`CPU Usage`, "cpu", 1, 0.4]]
} else if (chartName === "dio") {
return [
[t({ message: "Write", comment: "Disk write" }), "dw", 3, 0.3],
[t({ message: "Read", comment: "Disk read" }), "dr", 1, 0.3],
]
} else if (chartName === "bw") {
return [
[t({ message: "Sent", comment: "Network bytes sent (upload)" }), "ns", 5, 0.2],
[t({ message: "Received", comment: "Network bytes received (download)" }), "nr", 2, 0.2],
]
} else if (chartName.startsWith("efs")) {
return [
[t`Write`, `${chartName}.w`, 3, 0.3],
[t`Read`, `${chartName}.r`, 1, 0.3],
]
} else if (chartName.startsWith("g.")) {
return [chartName.includes("mu") ? [t`Used`, chartName, 2, 0.25] : [t`Usage`, chartName, 1, 0.4]]
return useMemo(() => {
if (chartData.systemStats.length === 0) {
return null
}
return []
}, [chartName, i18n.locale])
// console.log('Rendered at', new Date())
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, max ?? "auto"]}
tickFormatter={(value) => {
let val: string
if (tickFormatter) {
val = tickFormatter(value)
} else {
val = toFixedWithoutTrailingZeros(value, 2) + unit
}
return updateYAxisWidth(val)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
if (contentFormatter) {
return contentFormatter(value)
}
return decimalString(value) + unit
}}
// indicator="line"
/>
}
/>
{dataKeys.map((key, i) => {
const color = `hsl(var(--chart-${key[2]}))`
return (
<Area
key={i}
dataKey={getNestedValue.bind(null, key[1], showMax)}
name={key[0]}
type="monotoneX"
fill={color}
fillOpacity={key[3]}
stroke={color}
isAnimationActive={false}
/>
)
// if (logRender) {
// console.log("Rendered at", new Date())
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
})
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
/>
}
/>
{dataPoints?.map((dataPoint, i) => {
const color = `hsl(var(--chart-${dataPoint.color}))`
return (
<Area
key={i}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
/>
)
})}
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
}, [chartData.systemStats.length, yAxisWidth, maxToggled])
}

View File

@@ -1,22 +1,13 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { memo, useMemo } from "react"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
chartMargin,
toFixedFloat,
getSizeAndUnit,
toFixedWithoutTrailingZeros,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils"
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { $containerFilter } from "@/lib/stores"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { ChartType } from "@/lib/enums"
import { ChartType, Unit } from "@/lib/enums"
export default memo(function ContainerChart({
dataKey,
@@ -30,6 +21,7 @@ export default memo(function ContainerChart({
unit?: string
}) {
const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
@@ -84,13 +76,14 @@ export default memo(function ContainerChart({
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = toFixedWithoutTrailingZeros(value, 2) + unit
const val = toFixedFloat(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
obj.tickFormatter = (value) => {
const { v, u } = getSizeAndUnit(value, false)
return updateYAxisWidth(`${toFixedFloat(v, 2)}${u}${isNetChart ? "/s" : ""}`)
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}
}
// tooltip formatter
@@ -99,12 +92,14 @@ export default memo(function ContainerChart({
try {
const sent = item?.payload?.[key]?.ns ?? 0
const received = item?.payload?.[key]?.nr ?? 0
const { value: receivedValue, unit: receivedUnit } = formatBytes(received, true, userSettings.unitNet, true)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, true)
return (
<span className="flex">
{decimalString(received)} MB/s
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sent)} MB/s
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
@@ -114,8 +109,8 @@ export default memo(function ContainerChart({
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { v, u } = getSizeAndUnit(item.value, false)
return decimalString(v, 2) + u
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return decimalString(value) + " " + unit
}
} else {
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit

View File

@@ -1,17 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
decimalString,
toFixedFloat,
chartMargin,
getSizeAndUnit,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { useLingui } from "@lingui/react/macro"
import { Unit } from "@/lib/enums"
export default memo(function DiskChart({
dataKey,
@@ -53,9 +46,9 @@ export default memo(function DiskChart({
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { v, u } = getSizeAndUnit(value)
return updateYAxisWidth(toFixedFloat(v, 2) + u)
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
@@ -66,8 +59,8 @@ export default memo(function DiskChart({
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
const { v, u } = getSizeAndUnit(value)
return decimalString(v) + u
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
}}
/>
}

View File

@@ -8,14 +8,7 @@ import {
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
@@ -72,7 +65,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
const val = toFixedFloat(value, 2)
return updateYAxisWidth(val + "W")
}}
tickLine={false}

View File

@@ -0,0 +1,96 @@
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils"
import { ChartData, SystemStats } from "@/types"
import { memo } from "react"
import { t } from "@lingui/core/macro"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { legacy: keyof SystemStats; color: string; label: string }[] = [
{
legacy: "l1",
color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }),
},
{
legacy: "l5",
color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }),
},
{
legacy: "l15",
color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }),
},
]
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-ignore
// itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value)}
/>
}
/>
{keys.map(({ legacy, color, label }, i) => {
const dataKey = (value: { stats: SystemStats }) => {
if (chartData.agentVersion.patch < 1) {
return value.stats?.[legacy]
}
return value.stats?.la?.[i] ?? value.stats?.[legacy]
}
return (
<Line
key={i}
dataKey={dataKey}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
)
})}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,9 +1,10 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { memo } from "react"
import { ChartData } from "@/types"
import { useLingui } from "@lingui/react/macro"
import { Unit } from "@/lib/enums"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
@@ -39,8 +40,8 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const val = toFixedFloat(value, 1)
return updateYAxisWidth(val + " GB")
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
)}
@@ -54,8 +55,11 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
// @ts-ignore
itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"}
// indicator="line"
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
}}
/>
}
/>

View File

@@ -1,20 +1,16 @@
import { t } from "@lingui/core/macro";
import { t } from "@lingui/core/macro"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
chartMargin,
} from "@/lib/utils"
import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils"
import { ChartData } from "@/types"
import { memo } from "react"
import { $userSettings } from "@/lib/stores"
import { useStore } from "@nanostores/react"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const userSettings = useStore($userSettings)
if (chartData.systemStats.length === 0) {
return null
@@ -33,11 +29,14 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, () => toFixedWithoutTrailingZeros(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => updateYAxisWidth(value + " GB")}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
<ChartTooltip
@@ -46,7 +45,11 @@ export default memo(function SwapChart({ chartData }: { chartData: ChartData })
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " GB"}
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"
/>
}

View File

@@ -12,17 +12,19 @@ import {
useYAxisWidth,
cn,
formatShortDate,
toFixedWithoutTrailingZeros,
decimalString,
toFixedFloat,
chartMargin,
formatTemperature,
decimalString,
} from "@/lib/utils"
import { ChartData } from "@/types"
import { memo, useMemo } from "react"
import { $temperatureFilter } from "@/lib/stores"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { useStore } from "@nanostores/react"
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) {
@@ -72,9 +74,9 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedWithoutTrailingZeros(value, 2)
return updateYAxisWidth(val + " °C")
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
}}
tickLine={false}
axisLine={false}
@@ -88,7 +90,10 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + " °C"}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return decimalString(value) + " " + unit
}}
filter={filter}
/>
}

View File

@@ -1,6 +1,8 @@
import {
AlertOctagonIcon,
BookIcon,
DatabaseBackupIcon,
FingerprintIcon,
LayoutDashboard,
LogsIcon,
MailIcon,
@@ -40,6 +42,16 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
return useMemo(() => {
const systems = $systems.get()
const SettingsShortcut = (
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
)
const AdminShortcut = (
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
)
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder={t`Search for systems or settings...`} />
@@ -58,7 +70,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<Server className="me-2 h-4 w-4" />
<Server className="me-2 size-4" />
<span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem>
@@ -75,7 +87,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<LayoutDashboard className="me-2 h-4 w-4" />
<LayoutDashboard className="me-2 size-4" />
<span>
<Trans>Dashboard</Trans>
</span>
@@ -89,13 +101,11 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<SettingsIcon className="me-2 h-4 w-4" />
<SettingsIcon className="me-2 size-4" />
<span>
<Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
{SettingsShortcut}
</CommandItem>
<CommandItem
keywords={["alerts"]}
@@ -104,13 +114,35 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
setOpen(false)
}}
>
<MailIcon className="me-2 h-4 w-4" />
<MailIcon className="me-2 size-4" />
<span>
<Trans>Notifications</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
{SettingsShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "tokens" }))
setOpen(false)
}}
>
<FingerprintIcon className="me-2 size-4" />
<span>
<Trans>Tokens & Fingerprints</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "alert-history" }))
setOpen(false)
}}
>
<AlertOctagonIcon className="me-2 size-4" />
<span>
<Trans>Alert History</Trans>
</span>
{SettingsShortcut}
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
@@ -118,7 +150,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<BookIcon className="me-2 size-4" />
<span>
<Trans>Documentation</Trans>
</span>
@@ -136,13 +168,11 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/"), "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
<UsersIcon className="me-2 size-4" />
<span>
<Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
@@ -150,13 +180,11 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/logs"), "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
<LogsIcon className="me-2 size-4" />
<span>
<Trans>Logs</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
onSelect={() => {
@@ -164,13 +192,11 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/backups"), "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
<DatabaseBackupIcon className="me-2 size-4" />
<span>
<Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
<CommandItem
keywords={["email"]}
@@ -179,13 +205,11 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
window.open(prependBasePath("/_/#/settings/mail"), "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />
<MailIcon className="me-2 size-4" />
<span>
<Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
{AdminShortcut}
</CommandItem>
</CommandGroup>
</>

View File

@@ -0,0 +1,99 @@
import { memo } from "react"
import { DropdownMenuContent, DropdownMenuItem } from "./ui/dropdown-menu"
import { copyToClipboard, getHubURL } from "@/lib/utils"
import { i18n } from "@lingui/core"
// const isbeta = beszel.hub_version.includes("beta")
// const imagetag = isbeta ? ":edge" : ""
/**
* Get the URL of the script to install the agent.
* @param path - The path to the script (e.g. "/brew").
* @returns The URL for the script.
*/
const getScriptUrl = (path: string = "") => {
return `https://get.beszel.dev${path}`
// no beta for now
// const url = new URL("https://get.beszel.dev")
// url.pathname = path
// if (isBeta) {
// url.searchParams.set("beta", "1")
// }
// return url.toString()
}
export function copyDockerCompose(port = "45876", publicKey: string, token: string) {
copyToClipboard(`services:
beszel-agent:
image: henrygd/beszel-agent
container_name: beszel-agent
restart: unless-stopped
network_mode: host
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./beszel_agent_data:/var/lib/beszel-agent
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
LISTEN: ${port}
KEY: '${publicKey}'
TOKEN: ${token}
HUB_URL: ${getHubURL()}`)
}
export function copyDockerRun(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -v ./beszel_agent_data:/var/lib/beszel-agent -e KEY="${publicKey}" -e LISTEN=${port} -e TOKEN="${token}" -e HUB_URL="${getHubURL()}" henrygd/beszel-agent`
)
}
export function copyLinuxCommand(port = "45876", publicKey: string, token: string, brew = false) {
let cmd = `curl -sL ${getScriptUrl(
brew ? "/brew" : ""
)} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}" -t "${token}" -url "${getHubURL()}"`
// brew script does not support --china-mirrors
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
cmd += ` --china-mirrors`
}
copyToClipboard(cmd)
}
export function copyWindowsCommand(port = "45876", publicKey: string, token: string) {
copyToClipboard(
`& iwr -useb ${getScriptUrl()} -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port} -Token "${token}" -Url "${getHubURL()}"`
)
}
export interface DropdownItem {
text: string
onClick?: () => void
url?: string
icons?: React.ComponentType<React.SVGProps<SVGSVGElement>>[]
}
export const InstallDropdown = memo(({ items }: { items: DropdownItem[] }) => {
return (
<DropdownMenuContent align="end">
{items.map((item, index) => {
const className = "cursor-pointer flex items-center gap-1.5"
return item.url ? (
<DropdownMenuItem key={index} asChild>
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
{item.text}{" "}
{item.icons?.map((Icon, iconIndex) => (
<Icon key={iconIndex} className="size-4" />
))}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
{item.text}{" "}
{item.icons?.map((Icon, iconIndex) => (
<Icon key={iconIndex} className="size-4" />
))}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
)
})

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -43,6 +43,14 @@ const showLoginFaliedToast = () => {
})
}
const getAuthProviderIcon = (provider: AuthProviderInfo) => {
let { name } = provider
if (name.startsWith("oidc")) {
name = "oidc"
}
return prependBasePath(`/_/images/oauth2/${name}.svg`)
}
export function UserAuthForm({
className,
isFirstRun,
@@ -165,8 +173,8 @@ export function UserAuthForm({
}, [])
return (
<div className={cn("grid gap-6", className)} {...props}>
{passwordEnabled && (
<div className={cn("grid gap-6", className)} {...props}>
{passwordEnabled && (
<>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5">
@@ -242,20 +250,20 @@ export function UserAuthForm({
</form>
{(isFirstRun || oauthEnabled) && (
// only show 'continue with' during onboarding or if we have auth providers
(<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
<Trans>Or continue with</Trans>
</span>
</div>
</div>)
</div>
)}
</>
)}
{oauthEnabled && (
{oauthEnabled && (
<div className="grid gap-2 -mt-1">
{authMethods.oauth2.providers.map((provider) => (
<button
@@ -273,7 +281,7 @@ export function UserAuthForm({
) : (
<img
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
src={prependBasePath(`/_/images/oauth2/${provider.name}.svg`)}
src={getAuthProviderIcon(provider)}
alt=""
// onError={(e) => {
// e.currentTarget.src = "/static/lock.svg"
@@ -285,16 +293,16 @@ export function UserAuthForm({
))}
</div>
)}
{!oauthEnabled && isFirstRun && (
{!oauthEnabled && isFirstRun && (
// only show GitHub button / dialog during onboarding
(<Dialog>
<DialogTrigger asChild>
<Dialog>
<DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
<span className="translate-y-[1px]">GitHub</span>
</button>
</DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
<DialogContent style={{ maxWidth: 440, width: "90%" }}>
<DialogHeader>
<DialogTitle>
<Trans>OAuth 2 / OIDC support</Trans>
@@ -318,9 +326,9 @@ export function UserAuthForm({
</p>
</div>
</DialogContent>
</Dialog>)
</Dialog>
)}
{passwordEnabled && !isFirstRun && (
{passwordEnabled && !isFirstRun && (
<Link
href={getPagePath($router, "forgot_password")}
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
@@ -328,6 +336,6 @@ export function UserAuthForm({
<Trans>Forgot password?</Trans>
</Link>
)}
</div>
);
</div>
)
}

View File

@@ -11,7 +11,7 @@ const routes = {
* The base path of the application.
* This is used to prepend the base path to all routes.
*/
export const basePath = globalThis.BESZEL.BASE_PATH || ""
export const basePath = BESZEL?.BASE_PATH || ""
/**
* Prepends the base path to the given path.
@@ -41,5 +41,12 @@ function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
}
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
return <a onClick={onClick} {...props}></a>
let clickFn = onClick
if (props.onClick) {
clickFn = (e) => {
onClick(e)
props.onClick?.(e)
}
}
return <a {...props} onClick={clickFn}></a>
}

View File

@@ -110,10 +110,14 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : (
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
)}
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}

View File

@@ -0,0 +1,385 @@
import { pb } from "@/lib/stores"
import { alertInfo, cn, formatDuration, formatShortDate } from "@/lib/utils"
import { AlertsHistoryRecord } from "@/types"
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
flexRender,
ColumnFiltersState,
SortingState,
VisibilityState,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { alertsHistoryColumns } from "../../alerts-history-columns"
import { Checkbox } from "@/components/ui/checkbox"
import { memo, useEffect, useState } from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
DownloadIcon,
Trash2Icon,
} from "lucide-react"
import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { useToast } from "@/components/ui/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Alert History</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>View your 200 most recent alerts.</Trans>
</p>
</div>
)
})
export default function AlertsHistoryDataTable() {
const [data, setData] = useState<AlertsHistoryRecord[]>([])
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
const [globalFilter, setGlobalFilter] = useState("")
const { toast } = useToast()
const [deleteOpen, setDeleteDialogOpen] = useState(false)
useEffect(() => {
let unsubscribe: (() => void) | undefined
const pbOptions = {
expand: "system",
fields: "id,name,value,state,created,resolved,expand.system.name",
}
// Initial load
pb.collection<AlertsHistoryRecord>("alerts_history")
.getList(0, 200, {
...pbOptions,
sort: "-created",
})
.then(({ items }) => setData(items))
// Subscribe to changes
;(async () => {
unsubscribe = await pb.collection("alerts_history").subscribe(
"*",
(e) => {
if (e.action === "create") {
setData((current) => [e.record as AlertsHistoryRecord, ...current])
}
if (e.action === "update") {
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as AlertsHistoryRecord) : r)))
}
if (e.action === "delete") {
setData((current) => current.filter((r) => r.id !== e.record.id))
}
},
pbOptions
)
})()
// Unsubscribe on unmount
return () => unsubscribe?.()
}, [])
const table = useReactTable({
data,
columns: [
{
id: "select",
header: ({ table }) => (
<Checkbox
className="ms-2"
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
...alertsHistoryColumns,
],
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const system = row.original.expand?.system?.name ?? ""
const name = row.getValue("name") ?? ""
const created = row.getValue("created") ?? ""
const search = String(filterValue).toLowerCase()
return (
system.toLowerCase().includes(search) ||
(name as string).toLowerCase().includes(search) ||
(created as string).toLowerCase().includes(search)
)
},
})
// Bulk delete handler
const handleBulkDelete = async () => {
setDeleteDialogOpen(false)
const selectedIds = table.getSelectedRowModel().rows.map((row) => row.original.id)
try {
let batch = pb.createBatch()
let inBatch = 0
for (const id of selectedIds) {
batch.collection("alerts_history").delete(id)
inBatch++
if (inBatch > 20) {
await batch.send()
batch = pb.createBatch()
inBatch = 0
}
}
inBatch && (await batch.send())
table.resetRowSelection()
} catch (e) {
toast({
variant: "destructive",
title: t`Error`,
description: `Failed to delete records.`,
})
}
}
// Export to CSV handler
const handleExportCSV = () => {
const selectedRows = table.getSelectedRowModel().rows
if (!selectedRows.length) return
const cells: Record<string, (record: AlertsHistoryRecord) => string> = {
system: (record) => record.expand?.system?.name || record.system,
name: (record) => alertInfo[record.name]?.name() || record.name,
value: (record) => record.value + (alertInfo[record.name]?.unit ?? ""),
state: (record) => (record.resolved ? t`Resolved` : t`Active`),
created: (record) => formatShortDate(record.created),
resolved: (record) => (record.resolved ? formatShortDate(record.resolved) : ""),
duration: (record) => (record.resolved ? formatDuration(record.created, record.resolved) : ""),
}
const csvRows = [Object.keys(cells).join(",")]
for (const row of selectedRows) {
const r = row.original
csvRows.push(
Object.values(cells)
.map((val) => val(r))
.join(",")
)
}
const blob = new Blob([csvRows.join("\n")], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = "alerts_history.csv"
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="@container w-full">
<div className="@3xl:flex items-end mb-4 gap-4">
<SectionIntro />
<div className="flex items-center gap-2 ms-auto mt-3 @3xl:mt-0">
{table.getFilteredSelectedRowModel().rows.length > 0 && (
<div className="fixed bottom-0 left-0 w-full p-4 grid grid-cols-2 items-center gap-4 z-50 backdrop-blur-md shrink-0 @lg:static @lg:p-0 @lg:w-auto @lg:gap-3">
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteDialogOpen(open)}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="h-9 shrink-0">
<Trash2Icon className="size-4 shrink-0" />
<span className="ms-1">
<Trans>Delete</Trans>
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>This will permanently delete all selected records from the database.</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleBulkDelete}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant="outline" className="h-10" onClick={handleExportCSV}>
<DownloadIcon className="size-4" />
<span className="ms-1">
<Trans>Export</Trans>
</span>
</Button>
</div>
)}
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="px-4 w-full max-w-full @3xl:w-64"
/>
</div>
</div>
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-3">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between ps-1 tabular-nums">
<div className="text-muted-foreground hidden flex-1 text-sm lg:flex">
<Trans>
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</Trans>
</div>
<div className="flex w-full items-center gap-8 lg:w-fit my-3">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
<Trans>Rows per page</Trans>
</Label>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">
{[10, 20, 50, 100, 200].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex w-fit items-center justify-center text-sm font-medium">
<Trans>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</Trans>
</div>
<div className="ms-auto flex items-center gap-2 lg:ms-0">
<Button
variant="outline"
className="hidden size-9 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeftIcon className="size-5" />
</Button>
<Button
variant="outline"
className="size-9"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<ChevronRightIcon className="size-5" />
</Button>
<Button
variant="outline"
className="hidden size-9 lg:flex"
size="icon"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to last page</span>
<ChevronsRightIcon className="size-5" />
</Button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -11,7 +11,7 @@ import { useState } from "react"
import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react/macro"
// import { setLang } from "@/lib/i18n"
import { Unit } from "@/lib/enums"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
@@ -101,6 +101,87 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</p>
</div>
<Separator />
<div className="space-y-2">
<div className="mb-4">
<h3 className="mb-1 text-lg font-medium">
<Trans comment="Temperature / network units">Unit preferences</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Change display units for metrics.</Trans>
</p>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="block" htmlFor="unitTemp">
<Trans>Temperature unit</Trans>
</Label>
<Select
name="unitTemp"
key={userSettings.unitTemp}
defaultValue={userSettings.unitTemp?.toString() || String(Unit.Celsius)}
>
<SelectTrigger id="unitTemp">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Celsius)}>
<Trans>Celsius (°C)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Fahrenheit)}>
<Trans>Fahrenheit (°F)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="block" htmlFor="unitNet">
<Trans comment="Context: Bytes or bits">Network unit</Trans>
</Label>
<Select
name="unitNet"
key={userSettings.unitNet}
defaultValue={userSettings.unitNet?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitNet">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="block" htmlFor="unitDisk">
<Trans>Disk unit</Trans>
</Label>
<Select
name="unitDisk"
key={userSettings.unitDisk}
defaultValue={userSettings.unitDisk?.toString() ?? String(Unit.Bytes)}
>
<SelectTrigger id="unitDisk">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={String(Unit.Bytes)}>
<Trans>Bytes (KB/s, MB/s, GB/s)</Trans>
</SelectItem>
<SelectItem value={String(Unit.Bits)}>
<Trans>Bits (Kbps, Mbps, Gbps)</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
<Button type="submit" className="flex items-center gap-1.5 disabled:opacity-100" disabled={isLoading}>
{isLoading ? <LoaderCircleIcon className="h-4 w-4 animate-spin" /> : <SaveIcon className="h-4 w-4" />}
<Trans>Save Settings</Trans>

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useStore } from "@nanostores/react"
import { $router } from "@/components/router.tsx"
import { getPagePath, redirectPage } from "@nanostores/router"
import { BellIcon, FileSlidersIcon, SettingsIcon } from "lucide-react"
import { BellIcon, FileSlidersIcon, FingerprintIcon, SettingsIcon, AlertOctagonIcon } from "lucide-react"
import { $userSettings, pb } from "@/lib/stores.ts"
import { toast } from "@/components/ui/use-toast.ts"
import { UserSettings } from "@/types.js"
@@ -15,6 +15,8 @@ import General from "./general.tsx"
import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx"
import { useLingui } from "@lingui/react/macro"
import Fingerprints from "./tokens-fingerprints.tsx"
import AlertsHistoryDataTable from "./alerts-history-data-table"
export async function saveSettings(newSettings: Partial<UserSettings>) {
try {
@@ -58,6 +60,17 @@ export default function SettingsLayout() {
href: getPagePath($router, "settings", { name: "notifications" }),
icon: BellIcon,
},
{
title: t`Tokens & Fingerprints`,
href: getPagePath($router, "settings", { name: "tokens" }),
icon: FingerprintIcon,
noReadOnly: true,
},
{
title: t`Alert History`,
href: getPagePath($router, "settings", { name: "alert-history" }),
icon: AlertOctagonIcon,
},
{
title: t`YAML Config`,
href: getPagePath($router, "settings", { name: "config" }),
@@ -77,7 +90,7 @@ export default function SettingsLayout() {
}, [])
return (
<Card className="pt-5 px-4 pb-8 sm:pt-6 sm:px-7">
<Card className="pt-5 px-4 pb-8 min-h-96 sm:pt-6 sm:px-7">
<CardHeader className="p-0">
<CardTitle className="mb-1">
<Trans>Settings</Trans>
@@ -88,11 +101,11 @@ export default function SettingsLayout() {
</CardHeader>
<CardContent className="p-0">
<Separator className="hidden md:block my-5" />
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-10">
<aside className="md:w-48 w-full">
<div className="flex flex-col gap-3.5 md:flex-row md:gap-5 lg:gap-12">
<aside className="md:max-w-52 min-w-40">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">
<div className="flex-1 min-w-0">
{/* @ts-ignore */}
<SettingsContent name={page?.params?.name ?? "general"} />
</div>
@@ -112,5 +125,9 @@ function SettingsContent({ name }: { name: string }) {
return <Notifications userSettings={userSettings} />
case "config":
return <ConfigYaml />
case "tokens":
return <Fingerprints />
case "alert-history":
return <AlertsHistoryDataTable />
}
}

View File

@@ -1,5 +1,5 @@
import React from "react"
import { cn, isAdmin } from "@/lib/utils"
import { cn, isAdmin, isReadOnlyUser } from "@/lib/utils"
import { buttonVariants } from "../../ui/button"
import { $router, Link, navigate } from "../../router"
import { useStore } from "@nanostores/react"
@@ -12,6 +12,7 @@ interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
title: string
icon?: React.FC<React.SVGProps<SVGSVGElement>>
admin?: boolean
noReadOnly?: boolean
}[]
}
@@ -31,9 +32,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
if (item.admin && !isAdmin()) return null
return (
<SelectItem key={item.href} value={item.href}>
<span className="flex items-center gap-2">
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
<span className="flex items-center gap-2 truncate">
{item.icon && <item.icon className="size-4" />}
<span className="truncate">{item.title}</span>
</span>
</SelectItem>
)
@@ -44,9 +45,9 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
</div>
{/* Desktop View */}
<nav className={cn("hidden md:grid gap-1", className)} {...props}>
<nav className={cn("hidden md:grid gap-1 sticky top-6", className)} {...props}>
{items.map((item) => {
if (item.admin && !isAdmin()) {
if ((item.admin && !isAdmin()) || (item.noReadOnly && isReadOnlyUser())) {
return null
}
return (
@@ -55,13 +56,12 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
href={item.href}
className={cn(
buttonVariants({ variant: "ghost" }),
"flex items-center gap-3",
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50",
"justify-start"
"flex items-center gap-3 justify-start truncate",
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
)}
>
{item.icon && <item.icon className="h-4 w-4" />}
{item.title}
{item.icon && <item.icon className="size-4 shrink-0" />}
<span className="truncate">{item.title}</span>
</Link>
)
})}

View File

@@ -0,0 +1,365 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { $publicKey, pb } from "@/lib/stores"
import { memo, useEffect, useMemo, useState } from "react"
import { Table, TableCell, TableHead, TableBody, TableRow, TableHeader } from "@/components/ui/table"
import { FingerprintRecord } from "@/types"
import {
CopyIcon,
FingerprintIcon,
KeyIcon,
MoreHorizontalIcon,
RotateCwIcon,
ServerIcon,
Trash2Icon,
} from "lucide-react"
import { toast } from "@/components/ui/use-toast"
import { cn, copyToClipboard, generateToken, getHubURL, isReadOnlyUser, tokenMap } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import {
copyDockerCompose,
copyDockerRun,
copyLinuxCommand,
copyWindowsCommand,
DropdownItem,
InstallDropdown,
} from "@/components/install-dropdowns"
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { redirectPage } from "@nanostores/router"
import { $router } from "@/components/router"
const pbFingerprintOptions = {
expand: "system",
fields: "id,fingerprint,token,system,expand.system.name",
}
function sortFingerprints(fingerprints: FingerprintRecord[]) {
return fingerprints.sort((a, b) => a.expand.system.name.localeCompare(b.expand.system.name))
}
const SettingsFingerprintsPage = memo(() => {
if (isReadOnlyUser()) {
redirectPage($router, "settings", { name: "general" })
}
const [fingerprints, setFingerprints] = useState<FingerprintRecord[]>([])
// Get fingerprint records on mount
useEffect(() => {
pb.collection("fingerprints")
.getFullList<FingerprintRecord>(pbFingerprintOptions)
.then((prints) => {
setFingerprints(sortFingerprints(prints))
})
}, [])
// Subscribe to fingerprint updates
useEffect(() => {
let unsubscribe: (() => void) | undefined
;(async () => {
// subscribe to fingerprint updates
unsubscribe = await pb.collection("fingerprints").subscribe(
"*",
(res) => {
setFingerprints((currentFingerprints) => {
if (res.action === "create") {
return sortFingerprints([...currentFingerprints, res.record as FingerprintRecord])
}
if (res.action === "update") {
return currentFingerprints.map((fingerprint) => {
if (fingerprint.id === res.record.id) {
return { ...fingerprint, ...res.record } as FingerprintRecord
}
return fingerprint
})
}
if (res.action === "delete") {
return currentFingerprints.filter((fingerprint) => fingerprint.id !== res.record.id)
}
return currentFingerprints
})
},
pbFingerprintOptions
)
})()
// unsubscribe on unmount
return () => unsubscribe?.()
}, [])
// Update token map whenever fingerprints change
useEffect(() => {
for (const fingerprint of fingerprints) {
tokenMap.set(fingerprint.system, fingerprint.token)
}
}, [fingerprints])
return (
<>
<SectionIntro />
<Separator className="my-4" />
<SectionUniversalToken />
<Separator className="my-4" />
<SectionTable fingerprints={fingerprints} />
</>
)
})
const SectionIntro = memo(() => {
return (
<div>
<h3 className="text-xl font-medium mb-2">
<Trans>Tokens & Fingerprints</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Tokens and fingerprints are used to authenticate WebSocket connections to the hub.</Trans>
</p>
<p className="text-sm text-muted-foreground leading-relaxed mt-1.5">
<Trans>
Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on
first connection.
</Trans>
</p>
</div>
)
})
const SectionUniversalToken = memo(() => {
const [token, setToken] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false)
async function updateToken(enable: number = -1) {
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
const data = await pb.send(`/api/beszel/universal-token`, {
query: {
token,
enable,
},
})
setToken(data.token)
setChecked(data.active)
setIsLoading(false)
}
useEffect(() => {
updateToken()
}, [])
return (
<div>
<h3 className="text-lg font-medium mb-2">
<Trans>Universal token</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
When enabled, this token allows agents to self-register without prior system creation. Expires after one hour
or on hub restart.
</Trans>
</p>
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
{!isLoading && (
<>
<Switch
defaultChecked={checked}
onCheckedChange={(checked) => {
updateToken(checked ? 1 : 0)
}}
/>
<span
className={cn(
"text-sm text-primary opacity-60 transition-opacity",
checked ? "opacity-100" : "select-none"
)}
>
{token}
</span>
<ActionsButtonUniversalToken token={token} checked={checked} />
</>
)}
</div>
</div>
)
})
const ActionsButtonUniversalToken = memo(({ token, checked }: { token: string; checked: boolean }) => {
const { t } = useLingui()
const publicKey = $publicKey.get()
const port = "45876"
const dropdownItems: DropdownItem[] = [
{
text: t({ message: "Copy docker compose", context: "Button to copy docker compose file content" }),
onClick: () => copyDockerCompose(port, publicKey, token),
icons: [DockerIcon],
},
{
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
onClick: () => copyDockerRun(port, publicKey, token),
icons: [DockerIcon],
},
{
text: t`Copy Linux command`,
onClick: () => copyLinuxCommand(port, publicKey, token),
icons: [TuxIcon],
},
{
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
onClick: () => copyLinuxCommand(port, publicKey, token, true),
icons: [TuxIcon, AppleIcon],
},
{
text: t({ message: "Windows command", context: "Button to copy install command" }),
onClick: () => copyWindowsCommand(port, publicKey, token),
icons: [WindowsIcon],
},
]
return (
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={!checked}
className={cn("transition-opacity", !checked && "opacity-50")}
>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<InstallDropdown items={dropdownItems} />
</DropdownMenu>
</div>
)
})
const SectionTable = memo(({ fingerprints = [] }: { fingerprints: FingerprintRecord[] }) => {
const { t } = useLingui()
const isReadOnly = isReadOnlyUser()
const headerCols = useMemo(
() => [
{
label: t`System`,
Icon: ServerIcon,
w: "11em",
},
{
label: t`Token`,
Icon: KeyIcon,
w: "20em",
},
{
label: t`Fingerprint`,
Icon: FingerprintIcon,
w: "20em",
},
],
[t]
)
return (
<div className="rounded-md border overflow-hidden w-full mt-4">
<Table>
<TableHeader>
<TableRow>
{headerCols.map((col) => (
<TableHead key={col.label} style={{ minWidth: col.w }}>
<span className="flex items-center gap-2">
<col.Icon className="size-4" />
{col.label}
</span>
</TableHead>
))}
{!isReadOnly && (
<TableHead className="w-0">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody className="whitespace-pre">
{fingerprints.map((fingerprint, i) => (
<TableRow key={i}>
<TableCell className="font-medium ps-5 py-2.5">{fingerprint.expand.system.name}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.token}</TableCell>
<TableCell className="font-mono text-[0.95em] py-2.5">{fingerprint.fingerprint}</TableCell>
{!isReadOnly && (
<TableCell className="py-2.5 px-4 xl:px-2">
<ActionsButtonTable fingerprint={fingerprint} />
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
})
async function updateFingerprint(fingerprint: FingerprintRecord, rotateToken = false) {
try {
await pb.collection("fingerprints").update(fingerprint.id, {
fingerprint: "",
token: rotateToken ? generateToken() : fingerprint.token,
})
} catch (error: any) {
toast({
title: t`Error`,
description: error.message,
})
}
}
const ActionsButtonTable = memo(({ fingerprint }: { fingerprint: FingerprintRecord }) => {
const envVar = `HUB_URL=${getHubURL()}\nTOKEN=${fingerprint.token}`
const copyEnv = () => copyToClipboard(envVar)
const copyYaml = () => copyToClipboard(envVar.replaceAll("=", ": "))
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyYaml}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy YAML</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyEnv}>
<CopyIcon className="me-2.5 size-4" />
<Trans context="Environment variables">Copy env</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint, true)}>
<RotateCwIcon className="me-2.5 size-4" />
<Trans>Rotate token</Trans>
</DropdownMenuItem>
{fingerprint.fingerprint && (
<DropdownMenuItem onSelect={() => updateFingerprint(fingerprint)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete fingerprint</Trans>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
})
export default SettingsFingerprintsPage

View File

@@ -11,7 +11,7 @@ import {
$temperatureFilter,
} from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import { ChartType, Os } from "@/lib/enums"
import { ChartType, Unit, Os } from "@/lib/enums"
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
import { useStore } from "@nanostores/react"
@@ -21,10 +21,12 @@ import ChartTimeSelect from "../charts/chart-time-select"
import {
chartTimeData,
cn,
decimalString,
formatBytes,
getHostDisplayValue,
getPbTimestamp,
getSizeAndUnit,
listen,
parseSemVer,
toFixedFloat,
useLocalStorage,
} from "@/lib/utils"
@@ -47,6 +49,7 @@ const DiskChart = lazy(() => import("../charts/disk-chart"))
const SwapChart = lazy(() => import("../charts/swap-chart"))
const TemperatureChart = lazy(() => import("../charts/temperature-chart"))
const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart"))
const LoadAverageChart = lazy(() => import("../charts/load-average-chart"))
const cache = new Map<string, any>()
@@ -131,6 +134,7 @@ export default function SystemDetail({ name }: { name: string }) {
const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true)
const isLongerChart = chartTime !== "1h"
const userSettings = $userSettings.get()
useEffect(() => {
document.title = `${name} / Beszel`
@@ -188,6 +192,7 @@ export default function SystemDetail({ name }: { name: string }) {
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion: parseSemVer(system?.info?.v),
}
}, [systemStats, containerData, direction])
@@ -368,6 +373,7 @@ export default function SystemDetail({ name }: { name: string }) {
// select field for switching between avg and max values
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const showMax = chartTime !== "1h" && maxValues
// if no data, show empty message
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
@@ -472,7 +478,20 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect}
>
<AreaChartDefault chartData={chartData} chartName="CPU Usage" maxToggled={maxValues} unit="%" />
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`CPU Usage`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: "1",
opacity: 0.4,
},
]}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
/>
</ChartCard>
{containerFilterBar && (
@@ -519,7 +538,32 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Throughput of root filesystem`}
cornerEl={maxValSelect}
>
<AreaChartDefault chartData={chartData} chartName="dio" maxToggled={maxValues} />
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t({ message: "Write", comment: "Disk write" }),
dataKey: ({ stats }) => (showMax ? stats?.dwm : stats?.dw),
color: "3",
opacity: 0.3,
},
{
label: t({ message: "Read", comment: "Disk read" }),
dataKey: ({ stats }) => (showMax ? stats?.drm : stats?.dr),
color: "1",
opacity: 0.3,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
/>
</ChartCard>
<ChartCard
@@ -529,7 +573,43 @@ export default function SystemDetail({ name }: { name: string }) {
cornerEl={maxValSelect}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault chartData={chartData} chartName="bw" maxToggled={maxValues} />
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`Sent`,
// use bytes if available, otherwise multiply old MB (can remove in future)
dataKey(data) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? data?.stats?.ns * 1024 * 1024
},
color: "5",
opacity: 0.2,
},
{
label: t`Received`,
dataKey(data) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? data?.stats?.nr * 1024 * 1024
},
color: "2",
opacity: 0.2,
},
]}
tickFormatter={(val) => {
let { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return decimalString(value, value >= 100 ? 1 : 2) + " " + unit
}}
/>
</ChartCard>
{containerFilterBar && containerData.length > 0 && (
@@ -563,6 +643,18 @@ export default function SystemDetail({ name }: { name: string }) {
</ChartCard>
)}
{/* Load Average chart */}
{chartData.agentVersion?.minor >= 12 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
>
<LoadAverageChart chartData={chartData} />
</ChartCard>
)}
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && (
<ChartCard
@@ -594,10 +686,6 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
const sizeFormatter = (value: number, decimals?: number) => {
const { v, u } = getSizeAndUnit(value, false)
return toFixedFloat(v, decimals || 1) + u
}
return (
<div key={id} className="contents">
<ChartCard
@@ -606,7 +694,19 @@ export default function SystemDetail({ name }: { name: string }) {
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<AreaChartDefault chartData={chartData} chartName={`g.${id}.u`} unit="%" />
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
color: "1",
opacity: 0.35,
},
]}
tickFormatter={(val) => toFixedFloat(val, 2) + "%"}
contentFormatter={({ value }) => decimalString(value) + "%"}
/>
</ChartCard>
<ChartCard
empty={dataEmpty}
@@ -616,10 +716,23 @@ export default function SystemDetail({ name }: { name: string }) {
>
<AreaChartDefault
chartData={chartData}
chartName={`g.${id}.mu`}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: "2",
opacity: 0.25,
},
]}
max={gpu.mt}
tickFormatter={sizeFormatter}
contentFormatter={(value) => sizeFormatter(value, 2)}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
}}
/>
</ChartCard>
</div>
@@ -653,7 +766,32 @@ export default function SystemDetail({ name }: { name: string }) {
description={t`Throughput of ${extraFsName}`}
cornerEl={maxValSelect}
>
<AreaChartDefault chartData={chartData} chartName={`efs.${extraFsName}`} maxToggled={maxValues} />
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Write`,
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
color: "3",
opacity: 0.3,
},
{
label: t`Read`,
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
color: "1",
opacity: 0.3,
},
]}
maxToggled={maxValues}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
return toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
/>
</ChartCard>
</div>
)

View File

@@ -63,12 +63,21 @@ import {
PenBoxIcon,
} from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $systems, pb } from "@/lib/stores"
import { $systems, $userSettings, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import {
cn,
copyToClipboard,
isReadOnlyUser,
useLocalStorage,
formatTemperature,
decimalString,
formatBytes,
parseSemVer,
} from "@/lib/utils"
import AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon } from "../ui/icons"
import { useLingui, Trans } from "@lingui/react/macro"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input"
@@ -80,11 +89,11 @@ import { Dialog } from "../ui/dialog"
type ViewMode = "table" | "grid"
function CellFormatter(info: CellContext<SystemRecord, unknown>) {
const val = (info.getValue() as number) || 0
const val = Number(info.getValue()) || 0
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-[3.3em]">{decimalString(val, 1)}%</span>
<span className="grow min-w-10 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span className="min-w-8">{decimalString(val, val >= 10 ? 1 : 2)}%</span>
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span
className={cn(
"absolute inset-0 w-full h-full origin-left",
@@ -144,7 +153,6 @@ export default function SystemsTable() {
}
return [
{
// size: 200,
size: 200,
minSize: 0,
accessorKey: "name",
@@ -163,9 +171,10 @@ export default function SystemsTable() {
return false
},
enableHiding: false,
invertSorting: false,
Icon: ServerIcon,
cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5">
<span className="flex gap-0.5 items-center text-base md:ps-1 md:pe-5">
<IndicatorDot system={info.row.original} />
<Button
data-nolink
@@ -174,76 +183,118 @@ export default function SystemsTable() {
onClick={() => copyToClipboard(info.getValue() as string)}
>
{info.getValue() as string}
<CopyIcon className="h-2.5 w-2.5" />
<CopyIcon className="size-2.5" />
</Button>
</span>
),
header: sortableHeader,
},
{
accessorKey: "info.cpu",
accessorFn: ({ info }) => info.cpu,
id: "cpu",
name: () => t`CPU`,
invertSorting: true,
cell: CellFormatter,
Icon: CpuIcon,
header: sortableHeader,
},
{
accessorKey: "info.mp",
// accessorKey: "info.mp",
accessorFn: ({ info }) => info.mp,
id: "memory",
name: () => t`Memory`,
invertSorting: true,
cell: CellFormatter,
Icon: MemoryStickIcon,
header: sortableHeader,
},
{
accessorKey: "info.dp",
accessorFn: ({ info }) => info.dp,
id: "disk",
name: () => t`Disk`,
invertSorting: true,
cell: CellFormatter,
Icon: HardDriveIcon,
header: sortableHeader,
},
{
accessorFn: (originalRow) => originalRow.info.g,
accessorFn: ({ info }) => info.g,
id: "gpu",
name: () => "GPU",
invertSorting: true,
sortUndefined: -1,
cell: CellFormatter,
Icon: GpuIcon,
header: sortableHeader,
},
{
accessorFn: (originalRow) => originalRow.info.b || 0,
id: "loadAverage",
accessorFn: ({ info }) => {
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array
if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
}
return sum
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
Icon: HourglassIcon,
header: sortableHeader,
cell(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
// agent version
const { minor, patch } = parseSemVer(sysInfo.v)
let loadAverages = sysInfo.la
// use legacy load averages if agent version is less than 12.1.0
if (!loadAverages || (minor === 12 && patch < 1)) {
loadAverages = [sysInfo.l1 ?? 0, sysInfo.l5 ?? 0, sysInfo.l15 ?? 0]
}
const max = Math.max(...loadAverages)
if (max === 0 && (status === "paused" || minor < 12)) {
return null
}
function getDotColor() {
const normalized = max / (sysInfo.t ?? 1)
if (status !== "up") return "bg-primary/30"
if (normalized < 0.7) return "bg-green-500"
if (normalized < 1) return "bg-yellow-500"
return "bg-red-600"
}
return (
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
<span className={cn("inline-block size-2 rounded-full me-0.5", getDotColor())} />
{loadAverages?.map((la, i) => (
<span key={i}>{decimalString(la, la >= 10 ? 1 : 2)}</span>
))}
</div>
)
},
},
{
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
id: "net",
name: () => t`Net`,
invertSorting: true,
size: 50,
size: 0,
Icon: EthernetIcon,
header: sortableHeader,
cell(info) {
const val = info.getValue() as number
const sys = info.row.original
if (sys.status === "paused") {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
return (
<span
className={cn("tabular-nums whitespace-nowrap", {
"ps-1": viewMode === "table",
})}
>
{decimalString(val, val >= 100 ? 1 : 2)} MB/s
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorFn: (originalRow) => originalRow.info.dt,
accessorFn: ({ info }) => info.dt,
id: "temp",
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
invertSorting: true,
sortUndefined: -1,
size: 50,
hideSort: true,
Icon: ThermometerIcon,
@@ -253,22 +304,20 @@ export default function SystemsTable() {
if (!val) {
return null
}
const userSettings = useStore($userSettings)
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return (
<span
className={cn("tabular-nums whitespace-nowrap", {
"ps-1.5": viewMode === "table",
})}
>
{decimalString(val)} °C
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
</span>
)
},
},
{
accessorKey: "info.v",
accessorFn: ({ info }) => info.v,
id: "agent",
name: () => t`Agent`,
invertSorting: true,
// invertSorting: true,
size: 50,
Icon: WifiIcon,
hideSort: true,
@@ -280,11 +329,7 @@ export default function SystemsTable() {
}
const system = info.row.original
return (
<span
className={cn("flex gap-2 items-center md:pe-5 tabular-nums", {
"ps-1": viewMode === "table",
})}
>
<span className={cn("flex gap-2 items-center md:pe-5 tabular-nums", viewMode === "table" && "ps-0.5")}>
<IndicatorDot
system={system}
className={
@@ -293,7 +338,7 @@ export default function SystemsTable() {
"bg-yellow-500"
}
/>
<span>{info.getValue() as string}</span>
<span className="truncate max-w-14">{info.getValue() as string}</span>
</span>
)
},
@@ -304,7 +349,7 @@ export default function SystemsTable() {
name: () => t({ message: "Actions", comment: "Table column" }),
size: 50,
cell: ({ row }) => (
<div className="flex justify-end items-center gap-1">
<div className="flex justify-end items-center gap-1 -ms-3">
<AlertsButton system={row.original} />
<ActionsButton system={row.original} />
</div>
@@ -328,6 +373,9 @@ export default function SystemsTable() {
columnVisibility,
},
defaultColumn: {
// sortDescFirst: true,
invertSorting: true,
sortUndefined: "last",
minSize: 0,
size: 900,
maxSize: 900,
@@ -511,7 +559,7 @@ function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
<TableHead className="px-1.5" key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
@@ -11,13 +13,14 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-[.3em] border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
<Check className="size-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))

View File

@@ -0,0 +1,49 @@
import * as React from "react"
import { ChevronDownIcon, HourglassIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "./button"
interface CollapsibleProps {
title: string
children: React.ReactNode
description?: React.ReactNode
defaultOpen?: boolean
className?: string
icon?: React.ReactNode
}
export function Collapsible({ title, children, description, defaultOpen = false, className, icon }: CollapsibleProps) {
const [isOpen, setIsOpen] = React.useState(defaultOpen)
return (
<div className={cn("border rounded-lg", className)}>
<Button
variant="ghost"
className="w-full justify-between p-4 font-semibold"
onClick={() => setIsOpen(!isOpen)}
>
<div className="flex items-center gap-2">
{icon}
{title}
</div>
<ChevronDownIcon
className={cn("h-4 w-4 transition-transform duration-200", {
"rotate-180": isOpen,
})}
/>
</Button>
{description && (
<div className="px-4 pb-2 text-sm text-muted-foreground">
{description}
</div>
)}
{isOpen && (
<div className="px-4 pb-4">
<div className="grid gap-3">
{children}
</div>
</div>
)}
</div>
)
}

View File

@@ -121,3 +121,12 @@ export function GpuIcon(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
// Remix icons (Apache 2.0) https://github.com/Remix-Design/RemixIcon/blob/master/License
export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M4 2h16v4.5L13.5 12l6.5 5.5V22H4v-4.5l6.5-5.5L4 6.5zm12.3 5L18 5.5V4H6v1.5L7.7 7zM12 13.3l-6 5.2V20h1l5-3 5 3h1v-1.5z" />
</svg>
)
}

View File

@@ -0,0 +1,38 @@
import { copyToClipboard } from "@/lib/utils"
import { Input } from "./input"
import { Trans } from "@lingui/react/macro"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
import { CopyIcon } from "lucide-react"
import { Button } from "./button"
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
return (
<div className="relative">
<Input readOnly id={id} name={name} value={value} required></Input>
<div
className={
"h-6 w-24 bg-gradient-to-r rtl:bg-gradient-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
}
></div>
<TooltipProvider delayDuration={100} disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant={"link"}
className="absolute end-0 top-0"
onClick={() => copyToClipboard(value)}
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
<Trans>Click to copy</Trans>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View File

@@ -37,7 +37,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
<tr
ref={ref}
className={cn(
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:bg-muted",
"border-b border-border/60 hover:bg-muted/40 dark:hover:bg-muted/20 data-[state=selected]:!bg-muted",
className
)}
{...props}

Some files were not shown because too many files have changed in this diff Show More