Compare commits

..

1 Commits

Author SHA1 Message Date
henrygd
ea354ec030 initial nvml collector 2025-12-21 14:33:04 -05:00
134 changed files with 2523 additions and 6201 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1,2 +0,0 @@
# Everything needs to be reviewed by Hank
* @henrygd

View File

@@ -1,19 +0,0 @@
body:
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: textarea
attributes:
label: Description
description: Please describe in detail what you want to share.
validations:
required: true

View File

@@ -1,54 +1,19 @@
body:
- type: checkboxes
id: terms
- type: markdown
attributes:
label: Welcome!
description: |
Thank you for reaching out to the Beszel community for support! To help us assist you better, please make sure to review the following points before submitting your request:
value: |
### Before opening a discussion:
Please note:
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
**- Please do not submit support reqeusts that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and discussions and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
id: description
attributes:
label: Problem Description
description: |
How to write a good bug report?
- Respect the issue template as much as possible.
- The title should be short and descriptive.
- Explain the conditions which led you to report this issue: the context.
- The context should lead to something, a problem that youre facing.
- Remain clear and concise.
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
label: Description
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
validations:
required: true
- type: input
id: system
attributes:
@@ -56,15 +21,13 @@ body:
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations:
required: true
# - type: input
# id: version
# attributes:
# label: Beszel version
# placeholder: 0.9.1
# validations:
# required: true
- type: input
id: version
attributes:
label: Beszel version
placeholder: 0.9.1
validations:
required: true
- type: dropdown
id: install-method
attributes:
@@ -78,21 +41,18 @@ body:
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service configuration
render: yaml
- type: textarea
id: hub-logs
attributes:
label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json
- type: textarea
id: agent-logs
attributes:

View File

@@ -1,30 +1,8 @@
name: 🐛 Bug report
description: Use this template to report a bug or issue.
description: Report a new bug or issue.
title: '[Bug]: '
labels: ['bug']
labels: ['bug', "needs confirmation"]
body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions/new?category=support)** instead
Please note:
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed.
**- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown
id: component
attributes:
@@ -34,53 +12,81 @@ body:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: markdown
attributes:
value: |
### Thanks for taking the time to fill out this bug report!
- For more general support, please [start a support thread](https://github.com/henrygd/beszel/discussions/new?category=support).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.
### Before submitting a bug report:
- Check the [common issues guide](https://beszel.dev/guide/common-issues).
- Search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
id: description
attributes:
label: Problem Description
description: |
How to write a good bug report?
- Respect the issue template as much as possible.
- The title should be short and descriptive.
- Explain the conditions which led you to report this issue: the context.
- The context should lead to something, a problem that youre facing.
- Remain clear and concise.
- Format your messages to help the reader focus on what matters and understand the structure of your message, use [Markdown syntax](https://help.github.com/articles/github-flavored-markdown)
label: Description
description: Explain the issue you experienced clearly and concisely.
placeholder: I went to the coffee pot and it was empty.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: |
In a perfect world, what should have happened?
**Important:** Be specific. Vague descriptions like "it should work" are not helpful.
description: In a perfect world, what should have happened?
placeholder: When I got to the coffee pot, it should have been full.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: |
Provide detailed, numbered steps that someone else can follow to reproduce the issue.
**Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed.
Include specific actions, URLs, button clicks, and any relevant data or configuration.
description: Describe how to reproduce the issue in repeatable steps.
placeholder: |
1. Go to the coffee pot.
2. Make more coffee.
3. Pour it into a cup.
4. Observe that the cup is empty instead of full.
validations:
required: true
- type: 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:
@@ -88,7 +94,6 @@ body:
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations:
required: true
- type: input
id: version
attributes:
@@ -96,7 +101,6 @@ body:
placeholder: 0.9.1
validations:
required: true
- type: dropdown
id: install-method
attributes:
@@ -110,21 +114,18 @@ body:
- Other (please describe above)
validations:
required: true
- type: textarea
id: config
attributes:
label: Configuration
description: Please provide any relevant service configuration
render: yaml
- type: textarea
id: hub-logs
attributes:
label: Hub Logs
description: Check the logs page in PocketBase (`/_/#/logs`) for relevant errors (copy JSON).
render: json
- type: textarea
id: agent-logs
attributes:

View File

@@ -1,8 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 🗣️ Translations
url: https://crowdin.com/project/beszel
about: Please report translation issues and request new translations here.
- name: 💬 Support and questions
url: https://github.com/henrygd/beszel/discussions
about: Ask and answer questions here.

View File

@@ -1,25 +1,8 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement"]
labels: ["enhancement", "needs review"]
body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions)** instead
Please note:
- For **Bug reports**, use the [Bug Form](https://github.com/henrygd/beszel/issues/new?template=bug_report.yml).
- Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/beszel).
- Create one issue per feature request. This helps us keep track of requests and prioritize them accordingly.
options:
- label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist.
required: true
- label: This is a feature request, not a bug report or support question.
required: true
- type: dropdown
id: component
attributes:
@@ -29,29 +12,65 @@ body:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: textarea
id: description
- type: markdown
attributes:
label: Description
description: |
Describe the solution or feature you'd like. Explain what problem this solves or what value it adds.
**Important:** Be specific and detailed. Vague requests like "make it better" will be closed.
placeholder: |
Example:
- What is the feature?
- What problem does it solve?
- How should it work?
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
attributes:
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

View File

@@ -51,8 +51,7 @@ jobs:
# Labels
stale-issue-label: 'stale'
remove-stale-when-updated: true
any-of-labels: 'awaiting-requester'
exempt-issue-labels: 'enhancement'
only-issue-labels: 'awaiting-requester'
# Exemptions
exempt-assignees: 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

@@ -76,18 +76,6 @@ builds:
- goos: windows
goarch: riscv64
- id: beszel-agent-linux-amd64-glibc
binary: beszel-agent
main: internal/cmd/agent/agent.go
env:
- CGO_ENABLED=0
flags:
- -tags=glibc
goos:
- linux
goarch:
- amd64
archives:
- id: beszel-agent
formats: [tar.gz]
@@ -101,15 +89,6 @@ archives:
- goos: windows
formats: [zip]
- id: beszel-agent-linux-amd64-glibc
formats: [tar.gz]
ids:
- beszel-agent-linux-amd64-glibc
name_template: >-
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}_glibc
- id: beszel
formats: [tar.gz]
ids:
@@ -158,7 +137,9 @@ nfpms:
- debconf
scripts:
templates: ./supplemental/debian/templates
config: ./supplemental/debian/config.sh
# Currently broken due to a bug in goreleaser
# https://github.com/goreleaser/goreleaser/issues/5487
#config: ./supplemental/debian/config.sh
scoops:
- ids: [beszel-agent]

View File

@@ -5,8 +5,11 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -16,6 +19,7 @@ import (
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh"
)
@@ -61,7 +65,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.netIoStats = make(map[uint16]system.NetIoStats)
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
agent.dataDir, err = GetDataDir(dataDir...)
agent.dataDir, err = getDataDir(dataDir...)
if err != nil {
slog.Warn("Data directory not found")
} else {
@@ -224,12 +228,38 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
return data
}
// Start initializes and starts the agent with optional WebSocket connection
// 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 {
return GetFingerprint(a.dataDir, a.systemDetails.Hostname, a.systemDetails.CpuModel)
// 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()
// we ignore a commonly known "product_uuid" known not to be unique
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
fingerprint = a.systemDetails.Hostname + a.systemDetails.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

@@ -65,7 +65,7 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
continue
}
totalCapacity += bat.Full
totalCharge += min(bat.Current, bat.Full)
totalCharge += bat.Current
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}

View File

@@ -15,6 +15,9 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
@@ -256,16 +259,40 @@ func (client *WebSocketClient) sendMessage(data any) error {
return err
}
// sendResponse sends a response with optional request ID.
// For ID-based requests, we must populate legacy typed fields for backward
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
// sendResponse sends a response with optional request ID for the new protocol
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
if requestID != nil {
response := newAgentResponse(data, requestID)
// New format with ID - use typed fields
response := common.AgentResponse{
Id: requestID,
}
// Set the appropriate typed field based on data type
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
// case []byte:
// response.RawBytes = v
// case string:
// response.RawBytes = []byte(v)
default:
// For any other type, convert to error
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return client.sendMessage(response)
} else {
// Legacy format - send data directly
return client.sendMessage(data)
}
// Legacy format - send data directly
return client.sendMessage(data)
}
// getUserAgent returns one of two User-Agent strings based on current time.

View File

@@ -8,10 +8,10 @@ import (
"runtime"
)
// GetDataDir returns the path to the data directory for the agent and an error
// 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) {
func getDataDir(dataDirs ...string) (string, error) {
if len(dataDirs) > 0 {
return testDataDirs(dataDirs)
}

View File

@@ -17,7 +17,7 @@ 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)
result, err := getDataDir(tempDir)
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
@@ -26,7 +26,7 @@ func TestGetDataDir(t *testing.T) {
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)
result, err := getDataDir(newDir)
require.NoError(t, err)
assert.Equal(t, newDir, result)
@@ -52,7 +52,7 @@ func TestGetDataDir(t *testing.T) {
os.Setenv("BESZEL_AGENT_DATA_DIR", tempDir)
result, err := GetDataDir()
result, err := getDataDir()
require.NoError(t, err)
assert.Equal(t, tempDir, result)
})
@@ -60,7 +60,7 @@ func TestGetDataDir(t *testing.T) {
// 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)
_, err := getDataDir(invalidPath)
assert.Error(t, err)
})
@@ -79,7 +79,7 @@ func TestGetDataDir(t *testing.T) {
// 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()
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 {

View File

@@ -26,15 +26,6 @@ func parseFilesystemEntry(entry string) (device, customName string) {
return device, customName
}
func isDockerSpecialMountpoint(mountpoint string) bool {
switch mountpoint {
case "/etc/hosts", "/etc/resolv.conf", "/etc/hostname":
return true
default:
return false
}
}
// Sets up the filesystems to monitor for disk usage and I/O.
func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM")
@@ -78,15 +69,11 @@ func (a *Agent) initializeDiskInfo() {
if _, exists := a.fsStats[key]; !exists {
if root {
slog.Info("Detected root device", "name", key)
// Check if root device is in /proc/diskstats. Do not guess a
// fallback device for root: that can misattribute root I/O to a
// different disk while usage remains tied to root mountpoint.
// Check if root device is in /proc/diskstats, use fallback if not
if _, ioMatch = diskIoCounters[key]; !ioMatch {
if matchedKey, match := findIoDevice(filesystem, diskIoCounters); match {
key = matchedKey
ioMatch = true
} else {
slog.Warn("Root I/O unmapped; set FILESYSTEM", "device", device, "mountpoint", mountpoint)
key, ioMatch = findIoDevice(filesystem, diskIoCounters, a.fsStats)
if !ioMatch {
slog.Info("Using I/O fallback", "device", device, "mountpoint", mountpoint, "fallback", key)
}
}
} else {
@@ -154,8 +141,8 @@ func (a *Agent) initializeDiskInfo() {
for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == rootMountPoint || (isDockerSpecialMountpoint(p.Mountpoint) && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters)
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
if match {
addFsStat(fs, p.Mountpoint, true)
hasRoot = true
@@ -189,26 +176,33 @@ func (a *Agent) initializeDiskInfo() {
// If no root filesystem set, use fallback
if !hasRoot {
rootKey := filepath.Base(rootMountPoint)
if _, exists := a.fsStats[rootKey]; exists {
rootKey = "root"
}
slog.Warn("Root device not detected; root I/O disabled", "mountpoint", rootMountPoint)
a.fsStats[rootKey] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
}
a.initializeDiskIoStats(diskIoCounters)
}
// Returns matching device from /proc/diskstats.
// Returns matching device from /proc/diskstats,
// or the device with the most reads if no match is found.
// bool is true if a match was found.
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat) (string, bool) {
func findIoDevice(filesystem string, diskIoCounters map[string]disk.IOCountersStat, fsStats map[string]*system.FsStats) (string, bool) {
var maxReadBytes uint64
maxReadDevice := "/"
for _, d := range diskIoCounters {
if d.Name == filesystem || (d.Label != "" && d.Label == filesystem) {
return d.Name, true
}
if d.ReadBytes > maxReadBytes {
// don't use if device already exists in fsStats
if _, exists := fsStats[d.Name]; !exists {
maxReadBytes = d.ReadBytes
maxReadDevice = d.Name
}
}
}
return "", false
return maxReadDevice, false
}
// Sets start values for disk I/O stats.

View File

@@ -94,62 +94,6 @@ func TestParseFilesystemEntry(t *testing.T) {
}
}
func TestFindIoDevice(t *testing.T) {
t.Run("matches by device name", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"},
"sdb": {Name: "sdb"},
}
device, ok := findIoDevice("sdb", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sdb", device)
})
t.Run("matches by device label", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda", Label: "rootfs"},
"sdb": {Name: "sdb"},
}
device, ok := findIoDevice("rootfs", ioCounters)
assert.True(t, ok)
assert.Equal(t, "sda", device)
})
t.Run("returns no fallback when not found", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{
"sda": {Name: "sda"},
"sdb": {Name: "sdb"},
}
device, ok := findIoDevice("nvme0n1p1", ioCounters)
assert.False(t, ok)
assert.Equal(t, "", device)
})
}
func TestIsDockerSpecialMountpoint(t *testing.T) {
testCases := []struct {
name string
mountpoint string
expected bool
}{
{name: "hosts", mountpoint: "/etc/hosts", expected: true},
{name: "resolv", mountpoint: "/etc/resolv.conf", expected: true},
{name: "hostname", mountpoint: "/etc/hostname", expected: true},
{name: "root", mountpoint: "/", expected: false},
{name: "passwd", mountpoint: "/etc/passwd", expected: false},
{name: "extra-filesystem", mountpoint: "/extra-filesystems/sda1", expected: false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, isDockerSpecialMountpoint(tc.mountpoint))
})
}
}
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Set up environment variables
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")

View File

@@ -32,10 +32,6 @@ var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*
const (
// Docker API timeout in milliseconds
dockerTimeoutMs = 2100
// Number of consecutive /containers/json failures before forcing a client reset on old Docker versions
dockerClientResetFailureThreshold = 3
// Minimum time between Docker client resets to avoid reset flapping
dockerClientResetCooldown = 30 * time.Second
// Maximum realistic network speed (5 GB/s) to detect bad deltas
maxNetworkSpeedBps uint64 = 5e9
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
@@ -59,16 +55,12 @@ type dockerManager struct {
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
versionChecked bool // Whether docker version detection completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
transport *http.Transport // Base transport used by client for connection resets
consecutiveListFailures int // Number of consecutive /containers/json request failures
lastClientReset time.Time // Last time the Docker client connections were reset
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -127,10 +119,8 @@ func (dm *dockerManager) shouldExcludeContainer(name string) bool {
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
resp, err := dm.client.Get("http://localhost/containers/json")
if err != nil {
dm.handleContainerListError(err)
return nil, err
}
dm.consecutiveListFailures = 0
dm.apiContainerList = dm.apiContainerList[:0]
if err := dm.decode(resp, &dm.apiContainerList); err != nil {
@@ -214,50 +204,6 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
return stats, nil
}
func (dm *dockerManager) handleContainerListError(err error) {
dm.consecutiveListFailures++
if !dm.shouldResetDockerClient(err) {
return
}
dm.resetDockerClientConnections()
}
func (dm *dockerManager) shouldResetDockerClient(err error) bool {
if !dm.versionChecked || dm.goodDockerVersion {
return false
}
if dm.consecutiveListFailures < dockerClientResetFailureThreshold {
return false
}
if !dm.lastClientReset.IsZero() && time.Since(dm.lastClientReset) < dockerClientResetCooldown {
return false
}
return isDockerApiOverloadError(err)
}
func isDockerApiOverloadError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
msg := err.Error()
return strings.Contains(msg, "Client.Timeout exceeded") ||
strings.Contains(msg, "request canceled") ||
strings.Contains(msg, "context deadline exceeded") ||
strings.Contains(msg, "EOF")
}
func (dm *dockerManager) resetDockerClientConnections() {
if dm.transport == nil {
return
}
dm.transport.CloseIdleConnections()
dm.lastClientReset = time.Now()
slog.Warn("Reset Docker client connections after repeated /containers/json failures", "failures", dm.consecutiveListFailures)
}
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
// Initialize cache time maps if they don't exist
@@ -389,8 +335,6 @@ func validateCpuPercentage(cpuPct float64, containerName string) error {
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
stats.Cpu = twoDecimals(cpuPct)
stats.Mem = bytesToMegabytes(float64(usedMemory))
stats.Bandwidth = [2]uint64{sent_delta, recv_delta}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
stats.PrevReadTime = readTime
@@ -459,8 +403,6 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
// reset current stats
stats.Cpu = 0
stats.Mem = 0
stats.Bandwidth = [2]uint64{0, 0}
// TODO(0.19+): stop populating NetworkSent/NetworkRecv (deprecated in 0.18.3)
stats.NetworkSent = 0
stats.NetworkRecv = 0
@@ -607,7 +549,6 @@ func newDockerManager() *dockerManager {
Timeout: timeout,
Transport: userAgentTransport,
},
transport: transport,
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
@@ -666,7 +607,6 @@ func (dm *dockerManager) checkDockerVersion() {
if err := dm.decode(resp, &versionInfo); err != nil {
return
}
dm.versionChecked = true
// if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
dm.goodDockerVersion = true
@@ -754,8 +694,7 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
}
var builder strings.Builder
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
return "", err
}
@@ -767,11 +706,7 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return logs, nil
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
if !multiplexed {
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
return err
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
const headerSize = 8
var header [headerSize]byte
totalBytesRead := 0

View File

@@ -184,12 +184,11 @@ func TestUpdateContainerStatsValues(t *testing.T) {
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
assert.Equal(t, 1.0, stats.Mem)
// Check bandwidth (raw bytes)
assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
// Check network sent (should be converted to MB: 524288 bytes = 0.5 MB)
assert.Equal(t, 0.5, stats.NetworkSent)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, 0.5, stats.NetworkSent) // 524288 bytes = 0.5 MB
assert.Equal(t, 0.25, stats.NetworkRecv) // 262144 bytes = 0.25 MB
// Check network recv (should be converted to MB: 262144 bytes = 0.25 MB)
assert.Equal(t, 0.25, stats.NetworkRecv)
// Check read time
assert.Equal(t, testTime, stats.PrevReadTime)
@@ -528,10 +527,8 @@ func TestContainerStatsInitialization(t *testing.T) {
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, 1.0, stats.NetworkSent) // 1048576 bytes = 1 MB
assert.Equal(t, 0.5, stats.NetworkRecv) // 524288 bytes = 0.5 MB
assert.Equal(t, 1.0, stats.NetworkSent)
assert.Equal(t, 0.5, stats.NetworkRecv)
assert.Equal(t, testTime, stats.PrevReadTime)
}
@@ -692,8 +689,6 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
assert.Equal(t, cpuPct, testStats.Cpu)
assert.Equal(t, bytesToMegabytes(float64(usedMemory)), testStats.Mem)
assert.Equal(t, [2]uint64{1000000, 500000}, testStats.Bandwidth)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, bytesToMegabytes(1000000), testStats.NetworkSent)
assert.Equal(t, bytesToMegabytes(500000), testStats.NetworkRecv)
assert.Equal(t, testTime, testStats.PrevReadTime)
@@ -955,7 +950,6 @@ func TestDecodeDockerLogStream(t *testing.T) {
input []byte
expected string
expectError bool
multiplexed bool
}{
{
name: "simple log entry",
@@ -966,7 +960,6 @@ func TestDecodeDockerLogStream(t *testing.T) {
},
expected: "Hello World",
expectError: false,
multiplexed: true,
},
{
name: "multiple frames",
@@ -980,7 +973,6 @@ func TestDecodeDockerLogStream(t *testing.T) {
},
expected: "HelloWorld",
expectError: false,
multiplexed: true,
},
{
name: "zero length frame",
@@ -993,20 +985,12 @@ func TestDecodeDockerLogStream(t *testing.T) {
},
expected: "Hello",
expectError: false,
multiplexed: true,
},
{
name: "empty input",
input: []byte{},
expected: "",
expectError: false,
multiplexed: true,
},
{
name: "raw stream (not multiplexed)",
input: []byte("raw log content"),
expected: "raw log content",
multiplexed: false,
},
}
@@ -1014,7 +998,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
reader := bytes.NewReader(tt.input)
var builder strings.Builder
err := decodeDockerLogStream(reader, &builder, tt.multiplexed)
err := decodeDockerLogStream(reader, &builder)
if tt.expectError {
assert.Error(t, err)
@@ -1038,7 +1022,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
reader := bytes.NewReader(input)
var builder strings.Builder
err := decodeDockerLogStream(reader, &builder, true)
err := decodeDockerLogStream(reader, &builder)
assert.Error(t, err)
assert.Contains(t, err.Error(), "log frame size")
@@ -1072,7 +1056,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
reader := bytes.NewReader(input)
var builder strings.Builder
err := decodeDockerLogStream(reader, &builder, true)
err := decodeDockerLogStream(reader, &builder)
// Should complete without error (graceful truncation)
assert.NoError(t, err)

View File

@@ -1,87 +0,0 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/host"
)
const fingerprintFileName = "fingerprint"
// knownBadUUID is a commonly known "product_uuid" that is not unique across systems.
const knownBadUUID = "03000200-0400-0500-0006-000700080009"
// GetFingerprint returns the agent fingerprint. It first tries to read a saved
// fingerprint from the data directory. If not found (or dataDir is empty), it
// generates one from system properties. The hostname and cpuModel parameters are
// used as fallback material if host.HostID() fails. If either is empty, they
// are fetched from the system automatically.
//
// If a new fingerprint is generated and a dataDir is provided, it is saved.
func GetFingerprint(dataDir, hostname, cpuModel string) string {
if dataDir != "" {
if fp, err := readFingerprint(dataDir); err == nil {
return fp
}
}
fp := generateFingerprint(hostname, cpuModel)
if dataDir != "" {
_ = SaveFingerprint(dataDir, fp)
}
return fp
}
// generateFingerprint creates a fingerprint from system properties.
// It tries host.HostID() first, falling back to hostname + cpuModel.
// If hostname or cpuModel are empty, they are fetched from the system.
func generateFingerprint(hostname, cpuModel string) string {
fingerprint, err := host.HostID()
if err != nil || fingerprint == "" || fingerprint == knownBadUUID {
if hostname == "" {
hostname, _ = os.Hostname()
}
if cpuModel == "" {
if info, err := cpu.Info(); err == nil && len(info) > 0 {
cpuModel = info[0].ModelName
}
}
fingerprint = hostname + cpuModel
}
sum := sha256.Sum256([]byte(fingerprint))
return hex.EncodeToString(sum[:24])
}
// readFingerprint reads the saved fingerprint from the data directory.
func readFingerprint(dataDir string) (string, error) {
fp, err := os.ReadFile(filepath.Join(dataDir, fingerprintFileName))
if err != nil {
return "", err
}
s := strings.TrimSpace(string(fp))
if s == "" {
return "", errors.New("fingerprint file is empty")
}
return s, nil
}
// SaveFingerprint writes the fingerprint to the data directory.
func SaveFingerprint(dataDir, fingerprint string) error {
return os.WriteFile(filepath.Join(dataDir, fingerprintFileName), []byte(fingerprint), 0o644)
}
// DeleteFingerprint removes the saved fingerprint file from the data directory.
// Returns nil if the file does not exist (idempotent).
func DeleteFingerprint(dataDir string) error {
err := os.Remove(filepath.Join(dataDir, fingerprintFileName))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}

View File

@@ -1,103 +0,0 @@
//go:build testing
// +build testing
package agent
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetFingerprint(t *testing.T) {
t.Run("reads existing fingerprint from file", func(t *testing.T) {
dir := t.TempDir()
expected := "abc123def456"
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(expected), 0644)
require.NoError(t, err)
fp := GetFingerprint(dir, "", "")
assert.Equal(t, expected, fp)
})
t.Run("trims whitespace from file", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, fingerprintFileName), []byte(" abc123 \n"), 0644)
require.NoError(t, err)
fp := GetFingerprint(dir, "", "")
assert.Equal(t, "abc123", fp)
})
t.Run("generates fingerprint when file does not exist", func(t *testing.T) {
dir := t.TempDir()
fp := GetFingerprint(dir, "", "")
assert.NotEmpty(t, fp)
})
t.Run("generates fingerprint when dataDir is empty", func(t *testing.T) {
fp := GetFingerprint("", "", "")
assert.NotEmpty(t, fp)
})
t.Run("generates consistent fingerprint for same inputs", func(t *testing.T) {
fp1 := GetFingerprint("", "myhost", "mycpu")
fp2 := GetFingerprint("", "myhost", "mycpu")
assert.Equal(t, fp1, fp2)
})
t.Run("prefers saved fingerprint over generated", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, SaveFingerprint(dir, "saved-fp"))
fp := GetFingerprint(dir, "anyhost", "anycpu")
assert.Equal(t, "saved-fp", fp)
})
}
func TestSaveFingerprint(t *testing.T) {
t.Run("saves fingerprint to file", func(t *testing.T) {
dir := t.TempDir()
err := SaveFingerprint(dir, "abc123")
require.NoError(t, err)
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
require.NoError(t, err)
assert.Equal(t, "abc123", string(content))
})
t.Run("overwrites existing fingerprint", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, SaveFingerprint(dir, "old"))
require.NoError(t, SaveFingerprint(dir, "new"))
content, err := os.ReadFile(filepath.Join(dir, fingerprintFileName))
require.NoError(t, err)
assert.Equal(t, "new", string(content))
})
}
func TestDeleteFingerprint(t *testing.T) {
t.Run("deletes existing fingerprint", func(t *testing.T) {
dir := t.TempDir()
fp := filepath.Join(dir, fingerprintFileName)
err := os.WriteFile(fp, []byte("abc123"), 0644)
require.NoError(t, err)
err = DeleteFingerprint(dir)
require.NoError(t, err)
// Verify file is gone
_, err = os.Stat(fp)
assert.True(t, os.IsNotExist(err))
})
t.Run("no error when file does not exist", func(t *testing.T) {
dir := t.TempDir()
err := DeleteFingerprint(dir)
assert.NoError(t, err)
})
}

View File

@@ -5,7 +5,6 @@ import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"maps"
"os/exec"
"regexp"
@@ -15,13 +14,14 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/exp/slog"
)
const (
// Commands
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
amdgpuCmd string = "amdgpu" // internal cmd for sysfs collection
tegraStatsCmd string = "tegrastats"
// Polling intervals
@@ -42,7 +42,6 @@ type GPUManager struct {
sync.Mutex
nvidiaSmi bool
rocmSmi bool
amdgpu bool
tegrastats bool
intelGpuStats bool
nvml bool
@@ -138,10 +137,10 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// use closure to avoid recompiling the regex
ramPattern := regexp.MustCompile(`RAM (\d+)/(\d+)MB`)
gr3dPattern := regexp.MustCompile(`GR3D_FREQ (\d+)%`)
tempPattern := regexp.MustCompile(`(?:tj|GPU)@(\d+\.?\d*)C`)
tempPattern := regexp.MustCompile(`tj@(\d+\.?\d*)C`)
// Orin Nano / NX do not have GPU specific power monitor
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV)\s+(\d+)mW|VDD_SYS_GPU\s+(\d+)/\d+`)
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
// jetson devices have only one gpu so we'll just initialize here
gpuData := &system.GPUData{Name: "GPU"}
@@ -170,13 +169,7 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
// Parse power usage
powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil {
// powerMatches[2] is the "(GPU_SOC|CPU_GPU_CV) <N>mW" capture
// powerMatches[3] is the "VDD_SYS_GPU <N>/<N>" capture
powerStr := string(powerMatches[2])
if powerStr == "" {
powerStr = string(powerMatches[3])
}
power, _ := strconv.ParseFloat(powerStr, 64)
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
gpuData.Power += power / milliwattsInAWatt
}
gpuData.Count++
@@ -239,11 +232,10 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
totalMemory, _ := strconv.ParseFloat(v.MemoryTotal, 64)
usage, _ := strconv.ParseFloat(v.Usage, 64)
id := v.ID
if _, ok := gm.GpuDataMap[id]; !ok {
gm.GpuDataMap[id] = &system.GPUData{Name: v.Name}
if _, ok := gm.GpuDataMap[v.ID]; !ok {
gm.GpuDataMap[v.ID] = &system.GPUData{Name: v.Name}
}
gpu := gm.GpuDataMap[id]
gpu := gm.GpuDataMap[v.ID]
gpu.Temperature, _ = strconv.ParseFloat(v.Temperature, 64)
gpu.MemoryUsed = bytesToMegabytes(memoryUsage)
gpu.MemoryTotal = bytesToMegabytes(totalMemory)
@@ -306,13 +298,8 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
currentCount := uint32(gpu.Count)
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
// If no new data arrived
// If no new data arrived, use last known average
if deltaCount == 0 {
// If GPU appears suspended (instantaneous values are 0), return zero values
// Otherwise return last known average for temporary collection gaps
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
return system.GPUData{Name: gpu.Name}
}
return gm.lastAvgData[id] // zero value if not found
}
@@ -401,13 +388,7 @@ func (gm *GPUManager) detectGPUs() error {
gm.nvidiaSmi = true
}
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
gm.amdgpu = true
} else {
gm.rocmSmi = true
}
} else if gm.hasAmdSysfs() {
gm.amdgpu = true
gm.rocmSmi = true
}
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true
@@ -416,10 +397,10 @@ func (gm *GPUManager) detectGPUs() error {
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
gm.intelGpuStats = true
}
if gm.nvidiaSmi || gm.rocmSmi || gm.amdgpu || gm.tegrastats || gm.intelGpuStats || gm.nvml {
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
return nil
}
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or intel_gpu_top")
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
}
// startCollector starts the appropriate GPU data collector based on the command
@@ -456,12 +437,6 @@ func (gm *GPUManager) startCollector(command string) {
collector.cmdArgs = []string{"--interval", tegraStatsInterval}
collector.parse = gm.getJetsonParser()
go collector.start()
case amdgpuCmd:
go func() {
if err := gm.collectAmdStats(); err != nil {
slog.Warn("Error collecting AMD GPU data via sysfs", "err", err)
}
}()
case rocmSmiCmd:
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
collector.parse = gm.parseAmdData
@@ -473,7 +448,7 @@ func (gm *GPUManager) startCollector(command string) {
if failures > maxFailureRetries {
break
}
slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err)
slog.Warn("Error collecting AMD GPU data", "err", err)
}
time.Sleep(rocmSmiInterval)
}
@@ -511,9 +486,6 @@ func NewGPUManager() (*GPUManager, error) {
if gm.rocmSmi {
gm.startCollector(rocmSmiCmd)
}
if gm.amdgpu {
gm.startCollector(amdgpuCmd)
}
if gm.tegrastats {
gm.startCollector(tegraStatsCmd)
}

View File

@@ -1,184 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// hasAmdSysfs returns true if any AMD GPU sysfs nodes are found
func (gm *GPUManager) hasAmdSysfs() bool {
cards, err := filepath.Glob("/sys/class/drm/card*/device/vendor")
if err != nil {
return false
}
for _, vendorPath := range cards {
vendor, err := os.ReadFile(vendorPath)
if err == nil && strings.TrimSpace(string(vendor)) == "0x1002" {
return true
}
}
return false
}
// collectAmdStats collects AMD GPU metrics directly from sysfs to avoid the overhead of rocm-smi
func (gm *GPUManager) collectAmdStats() error {
cards, err := filepath.Glob("/sys/class/drm/card*")
if err != nil {
return err
}
var amdGpuPaths []string
for _, card := range cards {
// Ignore symbolic links and non-main card directories
if strings.Contains(filepath.Base(card), "-") || !isAmdGpu(card) {
continue
}
amdGpuPaths = append(amdGpuPaths, card)
}
if len(amdGpuPaths) == 0 {
return errNoValidData
}
slog.Debug("Using sysfs for AMD GPU data collection")
failures := 0
for {
hasData := false
for _, cardPath := range amdGpuPaths {
if gm.updateAmdGpuData(cardPath) {
hasData = true
}
}
if !hasData {
failures++
if failures > maxFailureRetries {
return errNoValidData
}
slog.Warn("No AMD GPU data from sysfs", "failures", failures)
time.Sleep(retryWaitTime)
continue
}
failures = 0
time.Sleep(rocmSmiInterval)
}
}
func isAmdGpu(cardPath string) bool {
vendorPath := filepath.Join(cardPath, "device/vendor")
vendor, err := os.ReadFile(vendorPath)
if err != nil {
return false
}
return strings.TrimSpace(string(vendor)) == "0x1002"
}
// updateAmdGpuData reads GPU metrics from sysfs and updates the GPU data map.
// Returns true if at least some data was successfully read.
func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
devicePath := filepath.Join(cardPath, "device")
id := filepath.Base(cardPath)
// Read all sysfs values first (no lock needed - these can be slow)
usage, usageErr := readSysfsFloat(filepath.Join(devicePath, "gpu_busy_percent"))
memUsed, memUsedErr := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_used"))
memTotal, _ := readSysfsFloat(filepath.Join(devicePath, "mem_info_vram_total"))
var temp, power float64
hwmons, _ := filepath.Glob(filepath.Join(devicePath, "hwmon/hwmon*"))
for _, hwmonDir := range hwmons {
if t, err := readSysfsFloat(filepath.Join(hwmonDir, "temp1_input")); err == nil {
temp = t / 1000.0
}
if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_average")); err == nil {
power += p / 1000000.0
} else if p, err := readSysfsFloat(filepath.Join(hwmonDir, "power1_input")); err == nil {
power += p / 1000000.0
}
}
// Check if we got any meaningful data
if usageErr != nil && memUsedErr != nil && temp == 0 {
return false
}
// Single lock to update all values atomically
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap[id]
if !ok {
gpu = &system.GPUData{Name: getAmdGpuName(devicePath)}
gm.GpuDataMap[id] = gpu
}
if usageErr == nil {
gpu.Usage += usage
}
gpu.MemoryUsed = bytesToMegabytes(memUsed)
gpu.MemoryTotal = bytesToMegabytes(memTotal)
gpu.Temperature = temp
gpu.Power += power
gpu.Count++
return true
}
func readSysfsFloat(path string) (float64, error) {
val, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strconv.ParseFloat(strings.TrimSpace(string(val)), 64)
}
// getAmdGpuName attempts to get a descriptive GPU name.
// First tries product_name (rarely available), then looks up the PCI device ID.
// Falls back to showing the raw device ID if not found in the lookup table.
func getAmdGpuName(devicePath string) string {
// Try product_name first (works for some enterprise GPUs)
if prod, err := os.ReadFile(filepath.Join(devicePath, "product_name")); err == nil {
return strings.TrimSpace(string(prod))
}
// Read PCI device ID and look it up
if deviceID, err := os.ReadFile(filepath.Join(devicePath, "device")); err == nil {
id := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(deviceID))), "0x")
if name, ok := getRadeonNames()[id]; ok {
return fmt.Sprintf("Radeon %s", name)
}
return fmt.Sprintf("AMD GPU (%s)", id)
}
return "AMD GPU"
}
// getRadeonNames returns the AMD GPU name lookup table
// Device IDs from https://pci-ids.ucw.cz/read/PC/1002
var getRadeonNames = sync.OnceValue(func() map[string]string {
return map[string]string{
"7550": "RX 9070",
"7590": "RX 9060 XT",
"7551": "AI PRO R9700",
"744c": "RX 7900",
"1681": "680M",
"7448": "PRO W7900",
"745e": "PRO W7800",
"7470": "PRO W7700",
"73e3": "PRO W6600",
"7422": "PRO W6400",
"7341": "PRO W5500",
}
})

View File

@@ -1,15 +0,0 @@
//go:build !linux
package agent
import (
"errors"
)
func (gm *GPUManager) hasAmdSysfs() bool {
return false
}
func (gm *GPUManager) collectAmdStats() error {
return errors.ErrUnsupported
}

View File

@@ -27,11 +27,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
defer gm.Unlock()
// only one gpu for now - cmd doesn't provide all by default
id := "i0" // prefix with i to avoid conflicts with nvidia card ids
gpuData, ok := gm.GpuDataMap[id]
gpuData, ok := gm.GpuDataMap["0"]
if !ok {
gpuData = &system.GPUData{Name: "GPU", Engines: make(map[string]float64)}
gm.GpuDataMap[id] = gpuData
gm.GpuDataMap["0"] = gpuData
}
gpuData.Power += sample.PowerGPU

View File

@@ -1,16 +1,14 @@
//go:build amd64 && (windows || (linux && glibc))
package agent
import (
"fmt"
"log/slog"
"strings"
"time"
"unsafe"
"github.com/ebitengine/purego"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/exp/slog"
)
// NVML constants and types
@@ -74,7 +72,7 @@ type nvmlCollector struct {
}
func (c *nvmlCollector) init() error {
slog.Debug("NVML: Initializing")
slog.Info("NVML: Initializing")
libPath := getNVMLPath()
lib, err := openLibrary(libPath)
@@ -145,80 +143,60 @@ func (c *nvmlCollector) collect() {
defer c.gm.Unlock()
for i, device := range c.devices {
id := fmt.Sprintf("%d", i)
bdf := c.bdfs[i]
// Update GPUDataMap
if _, ok := c.gm.GpuDataMap[id]; !ok {
var nameBuf [64]byte
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
continue
}
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
name = strings.TrimPrefix(name, "NVIDIA ")
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
}
gpu := c.gm.GpuDataMap[id]
if bdf != "" && !c.isGPUActive(bdf) {
slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf)
gpu.Temperature = 0
gpu.MemoryUsed = 0
slog.Info("NVML: GPU is suspended, skipping", "bdf", bdf)
continue
}
slog.Info("NVML: Collecting data for GPU", "bdf", bdf)
id := fmt.Sprintf("%d", i)
// Utilization
var utilization nvmlUtilization
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
gpu.Temperature = 0
gpu.MemoryUsed = 0
continue
}
slog.Debug("NVML: Collecting data for GPU", "bdf", bdf)
// Temperature
var temp uint32
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
// Memory: only poll if GPU is active to avoid leaving D3cold state (#1522)
if utilization.Gpu > 0 {
var usedMem, totalMem uint64
if c.isV2 {
var memory nvmlMemoryV2
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
} else {
var memory nvmlMemoryV1
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
}
if totalMem > 0 {
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
}
// Memory
var usedMem, totalMem uint64
if c.isV2 {
var memory nvmlMemoryV2
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory)))
usedMem = memory.Used
totalMem = memory.Total
} else {
slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf)
var memory nvmlMemoryV1
nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory)))
usedMem = memory.Used
totalMem = memory.Total
}
// Power
var power uint32
nvmlDeviceGetPowerUsage(device, &power)
// Update GPUDataMap
if _, ok := c.gm.GpuDataMap[id]; !ok {
var nameBuf [64]byte
nvmlDeviceGetName(device, &nameBuf[0], 64)
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
name = strings.TrimPrefix(name, "NVIDIA ")
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
}
gpu := c.gm.GpuDataMap[id]
gpu.Temperature = float64(temp)
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
gpu.Usage += float64(utilization.Gpu)
gpu.Power += float64(power) / 1000.0
gpu.Count++
slog.Debug("NVML: Collected data", "gpu", gpu)
slog.Info("NVML: Collected data", "gpu", gpu)
}
}

View File

@@ -1,14 +1,14 @@
//go:build glibc && linux && amd64
//go:build linux
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/ebitengine/purego"
"golang.org/x/exp/slog"
)
func openLibrary(name string) (uintptr, error) {
@@ -29,12 +29,12 @@ func (c *nvmlCollector) isGPUActive(bdf string) bool {
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
status, err := os.ReadFile(statusPath)
if err != nil {
slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
slog.Info("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
return true // Assume active if we can't read status
}
statusStr := strings.TrimSpace(string(status))
if statusStr != "active" && statusStr != "resuming" {
slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr)
slog.Info("NVML: GPU is not active", "bdf", bdf, "status", statusStr)
return false
}
@@ -47,7 +47,6 @@ func (c *nvmlCollector) isGPUActive(bdf string) bool {
if err == nil {
pstateStr := strings.TrimSpace(string(pstate))
if pstateStr != "D0" {
slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr)
return false
}
}

View File

@@ -1,21 +1,9 @@
//go:build (!linux && !windows) || !amd64 || (linux && !glibc)
//go:build !linux && !windows
package agent
import "fmt"
type nvmlCollector struct {
gm *GPUManager
}
func (c *nvmlCollector) init() error {
return fmt.Errorf("nvml not supported on this platform")
}
func (c *nvmlCollector) start() {}
func (c *nvmlCollector) collect() {}
func openLibrary(name string) (uintptr, error) {
return 0, fmt.Errorf("nvml not supported on this platform")
}

View File

@@ -1,4 +1,4 @@
//go:build windows && amd64
//go:build windows
package agent

View File

@@ -307,19 +307,6 @@ func TestParseJetsonData(t *testing.T) {
Count: 1,
},
},
{
name: "orin-style output with GPU@ temp and VDD_SYS_GPU power",
input: "RAM 3276/7859MB (lfb 5x4MB) SWAP 1626/12122MB (cached 181MB) CPU [44%@1421,49%@2031,67%@2034,17%@1420,25%@1419,8%@1420] EMC_FREQ 1%@1866 GR3D_FREQ 0%@114 APE 150 MTS fg 1% bg 1% PLL@42.5C MCPU@42.5C PMIC@50C Tboard@38C GPU@39.5C BCPU@42.5C thermal@41.3C Tdiode@39.25C VDD_SYS_GPU 182/182 VDD_SYS_SOC 730/730 VDD_4V0_WIFI 0/0 VDD_IN 5297/5297 VDD_SYS_CPU 1917/1917 VDD_SYS_DDR 1241/1241",
wantMetrics: &system.GPUData{
Name: "GPU",
MemoryUsed: 3276.0,
MemoryTotal: 7859.0,
Usage: 0.0,
Power: 0.182, // 182mW -> 0.182W
Temperature: 39.5,
Count: 1,
},
},
}
for _, tt := range tests {
@@ -838,7 +825,7 @@ func TestInitializeSnapshots(t *testing.T) {
}
func TestCalculateGPUAverage(t *testing.T) {
t.Run("returns cached average when deltaCount is zero", func(t *testing.T) {
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
@@ -851,10 +838,9 @@ func TestCalculateGPUAverage(t *testing.T) {
}
gpu := &system.GPUData{
Count: 10.0, // Same as snapshot, so delta = 0
Usage: 100.0,
Power: 200.0,
Temperature: 50.0, // Non-zero to avoid "suspended" check
Count: 10.0, // Same as snapshot, so delta = 0
Usage: 100.0,
Power: 200.0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
@@ -863,31 +849,6 @@ func TestCalculateGPUAverage(t *testing.T) {
assert.Equal(t, 100.0, result.Power, "Should return cached average")
})
t.Run("returns zero value when GPU is suspended", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
"0": {count: 10, usage: 100, power: 200},
},
},
lastAvgData: map[string]system.GPUData{
"0": {Usage: 50.0, Power: 100.0},
},
}
gpu := &system.GPUData{
Name: "Test GPU",
Count: 10.0,
Temperature: 0,
MemoryUsed: 0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 0.0, result.Usage, "Should return zero usage")
assert.Equal(t, 0.0, result.Power, "Should return zero power")
})
t.Run("calculates average for standard GPU", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
@@ -1385,7 +1346,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
ok := gm.updateIntelFromStats(&sample1)
assert.True(t, ok)
gpu := gm.GpuDataMap["i0"]
gpu := gm.GpuDataMap["0"]
require.NotNil(t, gpu)
assert.Equal(t, "GPU", gpu.Name)
assert.EqualValues(t, 10.5, gpu.Power)
@@ -1407,7 +1368,7 @@ func TestIntelUpdateFromStats(t *testing.T) {
ok = gm.updateIntelFromStats(&sample2)
assert.True(t, ok)
gpu = gm.GpuDataMap["i0"]
gpu = gm.GpuDataMap["0"]
require.NotNil(t, gpu)
assert.EqualValues(t, 10.5, gpu.Power)
assert.EqualValues(t, 30.0, gpu.Engines["Render/3D"]) // 20 + 10
@@ -1446,7 +1407,7 @@ echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50
t.Fatalf("collectIntelStats error: %v", err)
}
gpu := gm.GpuDataMap["i0"]
gpu := gm.GpuDataMap["0"]
require.NotNil(t, gpu)
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
assert.EqualValues(t, 6.0, gpu.Power)

View File

@@ -9,7 +9,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"log/slog"
"golang.org/x/exp/slog"
)
// HandlerContext provides context for request handlers

View File

@@ -9,31 +9,11 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"time"
)
// healthFile is the path to the health file
var healthFile = getHealthFilePath()
func getHealthFilePath() string {
filename := "beszel_health"
if runtime.GOOS == "linux" {
fullPath := filepath.Join("/dev/shm", filename)
if err := updateHealthFile(fullPath); err == nil {
return fullPath
}
}
return filepath.Join(os.TempDir(), filename)
}
func updateHealthFile(path string) error {
file, err := os.Create(path)
if err != nil {
return err
}
return file.Close()
}
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 {
@@ -50,7 +30,11 @@ func Check() error {
// Update updates the modification time of the health file
func Update() error {
return updateHealthFile(healthFile)
file, err := os.Create(healthFile)
if err != nil {
return err
}
return file.Close()
}
// CleanUp removes the health file

View File

@@ -52,12 +52,7 @@ class Program
foreach (var sensor in hardware.Sensors)
{
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
if (!validTemp ||
sensor.Name.IndexOf("Distance", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Critical", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Warning", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0)
if (!validTemp || sensor.Name.Contains("Distance"))
{
continue;
}

View File

@@ -3,11 +3,9 @@
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
</ItemGroup>
</Project>

View File

@@ -1,31 +0,0 @@
package agent
import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// newAgentResponse creates an AgentResponse using legacy typed fields.
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
default:
// For unknown types, use the generic Data field
response.Data, _ = cbor.Marshal(data)
}
return response
}

View File

@@ -13,7 +13,9 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
@@ -36,9 +38,6 @@ var hubVersions map[string]semver.Version
// and begins listening for connections. Returns an error if the server
// is already running or if there's an issue starting the server.
func (a *Agent) StartServer(opts ServerOptions) error {
if disableSSH, _ := GetEnv("DISABLE_SSH"); disableSSH == "true" {
return errors.New("SSH disabled")
}
if a.server != nil {
return errors.New("server already started")
}
@@ -166,9 +165,20 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
}
// responder that writes AgentResponse to stdout
// Uses legacy typed fields for backward compatibility with <= 0.17
sshResponder := func(data any, requestID *uint32) error {
response := newAgentResponse(data, requestID)
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
default:
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return cbor.NewEncoder(w).Encode(response)
}

View File

@@ -1,6 +1,3 @@
//go:build testing
// +build testing
package agent
import (
@@ -183,23 +180,6 @@ func TestStartServer(t *testing.T) {
}
}
func TestStartServerDisableSSH(t *testing.T) {
os.Setenv("BESZEL_AGENT_DISABLE_SSH", "true")
defer os.Unsetenv("BESZEL_AGENT_DISABLE_SSH")
agent, err := NewAgent("")
require.NoError(t, err)
opts := ServerOptions{
Network: "tcp",
Addr: ":45990",
}
err = agent.StartServer(opts)
assert.Error(t, err)
assert.Contains(t, err.Error(), "SSH disabled")
}
/////////////////////////////////////////////////////////////////
//////////////////// ParseKeys Tests ////////////////////////////
/////////////////////////////////////////////////////////////////

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
@@ -19,6 +18,8 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/smart"
"golang.org/x/exp/slog"
)
// SmartManager manages data collection for SMART devices
@@ -53,12 +54,6 @@ type DeviceInfo struct {
parserType string
}
// deviceKey is a composite key for a device, used to identify a device uniquely.
type deviceKey struct {
name string
deviceType string
}
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
// Refresh updates SMART data for all known devices
@@ -170,7 +165,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
configuredDevices = parsedDevices
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
@@ -207,11 +202,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
}
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
if splitChar == "" {
splitChar = ","
}
entries := strings.Split(config, splitChar)
entries := strings.Split(config, ",")
devices := make([]*DeviceInfo, 0, len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
@@ -335,13 +326,6 @@ func normalizeParserType(value string) string {
}
}
// makeDeviceKey creates a composite key from device name and type.
// This allows multiple drives under the same device path (e.g., RAID controllers)
// to be tracked separately.
func makeDeviceKey(name, deviceType string) deviceKey {
return deviceKey{name: name, deviceType: deviceType}
}
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
// it is not provided, and updates the device info when a parser succeeds.
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
@@ -451,7 +435,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
defer cancel()
// Try with -n standby first if we have existing data
args := sm.smartctlArgs(deviceInfo, hasExistingData)
args := sm.smartctlArgs(deviceInfo, true)
cmd := exec.CommandContext(ctx, sm.binPath, args...)
output, err := cmd.CombinedOutput()
@@ -514,12 +498,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// smartctlArgs returns the arguments for the smartctl command
// based on the device type and whether to include standby mode
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
args := make([]string, 0, 9)
var deviceType, parserType string
args := make([]string, 0, 7)
if deviceInfo != nil {
deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType)
deviceType := strings.ToLower(deviceInfo.Type)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
args = append(args, "-d", deviceInfo.Type)
@@ -527,13 +509,6 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
}
args = append(args, "-a", "--json=c")
effectiveType := parserType
if effectiveType == "" {
effectiveType = deviceType
}
if effectiveType == "sat" || effectiveType == "ata" {
args = append(args, "-l", "devstat")
}
if includeStandby {
args = append(args, "-n", "standby")
@@ -594,28 +569,6 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
return existing
}
// buildUniqueNameIndex returns devices that appear exactly once by name.
// It is used to safely apply name-only fallbacks without RAID ambiguity.
buildUniqueNameIndex := func(devices []*DeviceInfo) map[string]*DeviceInfo {
counts := make(map[string]int, len(devices))
for _, dev := range devices {
if dev == nil || dev.Name == "" {
continue
}
counts[dev.Name]++
}
unique := make(map[string]*DeviceInfo, len(counts))
for _, dev := range devices {
if dev == nil || dev.Name == "" {
continue
}
if counts[dev.Name] == 1 {
unique[dev.Name] = dev
}
}
return unique
}
// preserveVerifiedType copies the verified type/parser metadata from an existing
// device record so that subsequent scans/config updates never downgrade a
// previously verified device.
@@ -628,90 +581,69 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.parserType = prev.parserType
}
// applyConfiguredMetadata updates a matched device with any configured
// overrides, preserving verified type data when present.
applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) {
// Only update the type if it has not been verified yet; otherwise we
// keep the existing verified metadata intact.
if configuredDev.Type != "" && !existingDev.typeVerified {
newType := strings.TrimSpace(configuredDev.Type)
existingDev.Type = newType
existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType)
}
if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName
}
if configuredDev.Protocol != "" {
existingDev.Protocol = configuredDev.Protocol
}
}
existingIndex := make(map[deviceKey]*DeviceInfo, len(existing))
existingIndex := make(map[string]*DeviceInfo, len(existing))
for _, dev := range existing {
if dev == nil || dev.Name == "" {
continue
}
existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev
existingIndex[dev.Name] = dev
}
existingByName := buildUniqueNameIndex(existing)
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured))
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
// Start with the newly scanned devices so we always surface fresh metadata,
// but ensure we retain any previously verified parser assignment.
for _, scannedDevice := range scanned {
if scannedDevice == nil || scannedDevice.Name == "" {
for _, dev := range scanned {
if dev == nil || dev.Name == "" {
continue
}
// Work on a copy so we can safely adjust metadata without mutating the
// input slices that may be reused elsewhere.
copyDev := *scannedDevice
key := makeDeviceKey(copyDev.Name, copyDev.Type)
if prev := existingIndex[key]; prev != nil {
preserveVerifiedType(&copyDev, prev)
} else if prev := existingByName[copyDev.Name]; prev != nil {
copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
preserveVerifiedType(&copyDev, prev)
}
finalDevices = append(finalDevices, &copyDev)
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
}
deviceIndexByName := buildUniqueNameIndex(finalDevices)
// Merge configured devices on top so users can override scan results (except
// for verified type information).
for _, configuredDevice := range configured {
if configuredDevice == nil || configuredDevice.Name == "" {
for _, dev := range configured {
if dev == nil || dev.Name == "" {
continue
}
key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type)
if existingDev, ok := deviceIndex[key]; ok {
applyConfiguredMetadata(existingDev, configuredDevice)
continue
}
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
applyConfiguredMetadata(existingDev, configuredDevice)
if existingDev, ok := deviceIndex[dev.Name]; ok {
// Only update the type if it has not been verified yet; otherwise we
// keep the existing verified metadata intact.
if dev.Type != "" && !existingDev.typeVerified {
newType := strings.TrimSpace(dev.Type)
existingDev.Type = newType
existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType)
}
if dev.InfoName != "" {
existingDev.InfoName = dev.InfoName
}
if dev.Protocol != "" {
existingDev.Protocol = dev.Protocol
}
continue
}
copyDev := *configuredDevice
key = makeDeviceKey(copyDev.Name, copyDev.Type)
if prev := existingIndex[key]; prev != nil {
preserveVerifiedType(&copyDev, prev)
} else if prev := existingByName[copyDev.Name]; prev != nil {
copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
preserveVerifiedType(&copyDev, prev)
} else if copyDev.Type != "" {
copyDev.parserType = normalizeParserType(copyDev.Type)
}
finalDevices = append(finalDevices, &copyDev)
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
}
return finalDevices
@@ -729,14 +661,12 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
return
}
validKeys := make(map[deviceKey]struct{}, len(devices))
nameCounts := make(map[string]int, len(devices))
validNames := make(map[string]struct{}, len(devices))
for _, device := range devices {
if device == nil || device.Name == "" {
continue
}
validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{}
nameCounts[device.Name]++
validNames[device.Name] = struct{}{}
}
for key, data := range sm.SmartDataMap {
@@ -745,11 +675,7 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
continue
}
if data.DiskType == "" {
if nameCounts[data.DiskName] == 1 {
continue
}
} else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok {
if _, ok := validNames[data.DiskName]; ok {
continue
}
@@ -837,11 +763,6 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
smartData.Temperature = data.Temperature.Current
if smartData.Temperature == 0 {
if temp, ok := temperatureFromAtaDeviceStatistics(data.AtaDeviceStatistics); ok {
smartData.Temperature = temp
}
}
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
@@ -880,36 +801,6 @@ func getSmartStatus(temperature uint8, passed bool) string {
}
}
func temperatureFromAtaDeviceStatistics(stats smart.AtaDeviceStatistics) (uint8, bool) {
entry := findAtaDeviceStatisticsEntry(stats, 5, "Current Temperature")
if entry == nil || entry.Value == nil {
return 0, false
}
if *entry.Value > 255 {
return 0, false
}
return uint8(*entry.Value), true
}
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsEntry(stats smart.AtaDeviceStatistics, pageNumber uint8, entryName string) *smart.AtaDeviceStatisticsEntry {
for pageIdx := range stats.Pages {
page := &stats.Pages[pageIdx]
if page.Number != pageNumber {
continue
}
for entryIdx := range page.Table {
entry := &page.Table[entryIdx]
if !strings.EqualFold(entry.Name, entryName) {
continue
}
return entry
}
}
return nil
}
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
var data smart.SmartInfoForScsi
@@ -1124,6 +1015,7 @@ func NewSmartManager() (*SmartManager, error) {
sm.refreshExcludedDevices()
path, err := sm.detectSmartctl()
if err != nil {
slog.Debug(err.Error())
return nil, err
}
slog.Debug("smartctl", "path", path)

View File

@@ -89,39 +89,6 @@ func TestParseSmartForSata(t *testing.T) {
}
}
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
"device": {"name": "/dev/sdb", "type": "sat"},
"model_name": "SanDisk SSD U110 16GB",
"serial_number": "DEVSTAT123",
"firmware_version": "U21B001",
"user_capacity": {"bytes": 16013942784},
"smart_status": {"passed": true},
"ata_smart_attributes": {"table": []},
"ata_device_statistics": {
"pages": [
{
"number": 5,
"name": "Temperature Statistics",
"table": [
{"name": "Current Temperature", "value": 22, "flags": {"valid": true}}
]
}
]
}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
require.True(t, hasData)
assert.Equal(t, 0, exitStatus)
deviceData, ok := sm.SmartDataMap["DEVSTAT123"]
require.True(t, ok, "expected smart data entry for serial DEVSTAT123")
assert.Equal(t, uint8(22), deviceData.Temperature)
}
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 0},
@@ -228,24 +195,6 @@ func TestDevicesSnapshotReturnsCopy(t *testing.T) {
assert.Len(t, snapshot, 2)
}
func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) {
t.Setenv("SMART_DEVICES_SEPARATOR", "|")
t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme")
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
err := sm.ScanDevices(true)
require.NoError(t, err)
require.Len(t, sm.SmartDevices, 2)
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type)
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
}
func TestScanDevicesWithEnvOverride(t *testing.T) {
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
@@ -300,21 +249,15 @@ func TestSmartctlArgs(t *testing.T) {
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
sm.smartctlArgs(sataDevice, true),
)
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
sm.smartctlArgs(sataDevice, false),
)
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
assert.Equal(t,
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
sm.smartctlArgs(nvmeDevice, true),
)
assert.Equal(t,
[]string{"-a", "--json=c", "-n", "standby"},
sm.smartctlArgs(nil, true),
@@ -499,88 +442,6 @@ func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
assert.Equal(t, "", device.parserType)
}
func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) {
// There are use cases where the same device name is re-used,
// for example, a RAID controller with multiple drives.
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
{Name: "/dev/sda", Type: "megaraid,2"},
}
merged := mergeDeviceLists(nil, scanned, nil)
require.Len(t, merged, 3, "should have 3 separate devices for RAID controller")
byKey := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
key := dev.Name + "|" + dev.Type
byKey[key] = dev
}
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
assert.Contains(t, byKey, "/dev/sda|megaraid,2")
}
func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) {
// Test mixing RAID drives with regular devices
scanned := []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
{Name: "/dev/sdb", Type: "sat"},
{Name: "/dev/nvme0", Type: "nvme"},
}
merged := mergeDeviceLists(nil, scanned, nil)
require.Len(t, merged, 4, "should have 4 separate devices")
byKey := make(map[string]*DeviceInfo, len(merged))
for _, dev := range merged {
key := dev.Name + "|" + dev.Type
byKey[key] = dev
}
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
assert.Contains(t, byKey, "/dev/sdb|sat")
assert.Contains(t, byKey, "/dev/nvme0|nvme")
}
func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
// Test that updateSmartDevices correctly validates RAID drives using composite keys
sm := &SmartManager{
SmartDevices: []*DeviceInfo{
{Name: "/dev/sda", Type: "megaraid,0"},
{Name: "/dev/sda", Type: "megaraid,1"},
},
SmartDataMap: map[string]*smart.SmartData{
"serial-0": {
DiskName: "/dev/sda",
DiskType: "megaraid,0",
SerialNumber: "serial-0",
},
"serial-1": {
DiskName: "/dev/sda",
DiskType: "megaraid,1",
SerialNumber: "serial-1",
},
"serial-stale": {
DiskName: "/dev/sda",
DiskType: "megaraid,2",
SerialNumber: "serial-stale",
},
},
}
sm.updateSmartDevices(sm.SmartDevices)
// serial-0 and serial-1 should be preserved (matching devices exist)
assert.Contains(t, sm.SmartDataMap, "serial-0")
assert.Contains(t, sm.SmartDataMap, "serial-1")
// serial-stale should be removed (no matching device)
assert.NotContains(t, sm.SmartDataMap, "serial-stale")
}
func TestParseSmartOutputMarksVerified(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
data, err := os.ReadFile(fixturePath)

View File

@@ -8,7 +8,6 @@ import (
"log/slog"
"maps"
"math"
"os"
"strconv"
"strings"
"sync"
@@ -29,36 +28,11 @@ type systemdManager struct {
patterns []string
}
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548)
func isSystemdAvailable() bool {
paths := []string{
"/run/systemd/system",
"/run/dbus/system_bus_socket",
"/var/run/dbus/system_bus_socket",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return true
}
}
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
return strings.TrimSpace(string(data)) == "systemd"
}
return false
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil
}
// Check if systemd is available on the system before attempting connection
if !isSystemdAvailable() {
slog.Debug("Systemd not available")
return nil, nil
}
conn, err := dbus.NewSystemConnectionContext(context.Background())
if err != nil {
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
@@ -144,27 +118,13 @@ func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*syst
return nil
}
// Track which units are currently present to remove stale entries
currentUnits := make(map[string]struct{}, len(units))
for _, unit := range units {
currentUnits[unit.Name] = struct{}{}
service, err := sm.updateServiceStats(conn, unit)
if err != nil {
continue
}
services = append(services, service)
}
// Remove services that no longer exist in systemd
sm.Lock()
for unitName := range sm.serviceStatsMap {
if _, exists := currentUnits[unitName]; !exists {
delete(sm.serviceStatsMap, unitName)
}
}
sm.Unlock()
sm.hasFreshStats = true
return services
}

View File

@@ -19,11 +19,11 @@ func TestSystemdManagerGetServiceStats(t *testing.T) {
assert.NoError(t, err)
// Test with refresh = true
result := manager.getServiceStats("any-service", true)
result := manager.getServiceStats(true)
assert.Nil(t, result)
// Test with refresh = false
result = manager.getServiceStats("any-service", false)
result = manager.getServiceStats(false)
assert.Nil(t, result)
}

View File

@@ -4,7 +4,6 @@ package agent
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -49,35 +48,6 @@ func TestUnescapeServiceNameInvalid(t *testing.T) {
}
}
func TestIsSystemdAvailable(t *testing.T) {
// Note: This test's result will vary based on the actual system running the tests
// On systems with systemd, it should return true
// On systems without systemd, it should return false
result := isSystemdAvailable()
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
runSystemdExists := false
if _, err := os.Stat("/run/systemd/system"); err == nil {
runSystemdExists = true
}
pid1IsSystemd := false
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
}
expected := runSystemdExists || pid1IsSystemd
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
// Log the result for informational purposes
if result {
t.Log("Systemd is available on this system")
} else {
t.Log("Systemd is not available on this system")
}
}
func TestGetServicePatterns(t *testing.T) {
tests := []struct {
name string

View File

@@ -1,10 +1,12 @@
package agent
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"github.com/henrygd/beszel/internal/ghupdate"
)
@@ -63,9 +65,9 @@ func detectRestarter() restarter {
if path, err := exec.LookPath("rc-service"); err == nil {
return &openRCRestarter{cmd: path}
}
if path, err := exec.LookPath("procd"); err == nil {
return &openWRTRestarter{cmd: path}
}
if path, err := exec.LookPath("procd"); err == nil {
return &openWRTRestarter{cmd: path}
}
if path, err := exec.LookPath("service"); err == nil {
if runtime.GOOS == "freebsd" {
return &freeBSDRestarter{cmd: path}
@@ -79,7 +81,7 @@ func detectRestarter() restarter {
func Update(useMirror bool) error {
exePath, _ := os.Executable()
dataDir, err := GetDataDir()
dataDir, err := getDataDir()
if err != nil {
dataDir = os.TempDir()
}
@@ -106,12 +108,12 @@ func Update(useMirror bool) error {
}
}
// Fix SELinux context if necessary
if err := ghupdate.HandleSELinuxContext(exePath); err != nil {
// 6) Fix SELinux context if necessary
if err := handleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
}
// Restart service if running under a recognised init system
// 7) Restart service if running under a recognised init system
if r := detectRestarter(); r != nil {
if err := r.Restart(); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
@@ -125,3 +127,42 @@ func Update(useMirror bool) error {
return nil
}
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
func handleSELinuxContext(path string) error {
out, err := exec.Command("getenforce").Output()
if err != nil {
// SELinux not enabled or getenforce not available
return nil
}
state := strings.TrimSpace(string(out))
if state == "Disabled" {
return nil
}
ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
var errs []string
// Try persistent context via semanage+restorecon
if semanagePath, err := exec.LookPath("semanage"); err == nil {
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
errs = append(errs, "semanage fcontext failed: "+err.Error())
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
errs = append(errs, "restorecon failed: "+err.Error())
}
}
}
// Fallback to temporary context via chcon
if chconPath, err := exec.LookPath("chcon"); err == nil {
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
errs = append(errs, "chcon failed: "+err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
}
return nil
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.18.3"
Version = "0.18.0-beta.1"
// AppName is the name of the application.
AppName = "beszel"
)

42
go.mod
View File

@@ -1,27 +1,27 @@
module github.com/henrygd/beszel
go 1.25.7
go 1.25.5
require (
github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/distatus/battery v0.11.0
github.com/ebitengine/purego v0.9.1
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.1
github.com/nicholas-fedor/shoutrrr v0.12.1
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.36.2
github.com/shirou/gopsutil/v4 v4.26.1
github.com/pocketbase/pocketbase v0.34.0
github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0
golang.org/x/exp v0.0.0-20260112195511-716be5621a96
golang.org/x/sys v0.40.0
golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
golang.org/x/sys v0.38.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -34,15 +34,15 @@ require (
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/godbus/dbus/v5 v5.2.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -54,15 +54,15 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.35.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.44.3 // indirect
modernc.org/sqlite v1.40.1 // indirect
)

112
go.sum
View File

@@ -9,8 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -33,8 +33,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -51,26 +51,24 @@ 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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -85,19 +83,19 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.2 h1:mzrxnvXKc3yxKlvZdbwoYXkH8kfIETteD0hWdgj0VI4=
github.com/pocketbase/pocketbase v0.36.2/go.mod h1:71vSF8whUDzC8mcLFE10+Qatf9JQdeOGIRWawOuLLKM=
github.com/pocketbase/pocketbase v0.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
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=
@@ -105,12 +103,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -129,38 +127,38 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
@@ -173,20 +171,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -195,8 +191,8 @@ 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.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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

@@ -31,6 +31,9 @@ func (opts *cmdOptions) parse() bool {
// Subcommands that don't require any pflag parsing
switch subcommand {
case "-v", "version":
fmt.Println(beszel.AppName+"-agent", beszel.Version)
return true
case "health":
err := health.Check()
if err != nil {
@@ -38,9 +41,6 @@ func (opts *cmdOptions) parse() bool {
}
fmt.Print("ok")
return true
case "fingerprint":
handleFingerprint()
return true
}
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
@@ -49,7 +49,6 @@ func (opts *cmdOptions) parse() bool {
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
version := pflag.BoolP("version", "v", false, "Show version information")
help := pflag.BoolP("help", "h", false, "Show this help message")
// Convert old single-dash long flags to double-dash for backward compatibility
@@ -74,9 +73,9 @@ func (opts *cmdOptions) parse() bool {
builder.WriteString(os.Args[0])
builder.WriteString(" [command] [flags]\n")
builder.WriteString("\nCommands:\n")
builder.WriteString(" fingerprint View or reset the agent fingerprint\n")
builder.WriteString(" health Check if the agent is running\n")
builder.WriteString(" update Update to the latest version\n")
builder.WriteString(" health Check if the agent is running\n")
// builder.WriteString(" help Display this help message\n")
builder.WriteString(" update Update to the latest version\n")
builder.WriteString("\nFlags:\n")
fmt.Print(builder.String())
pflag.PrintDefaults()
@@ -87,9 +86,6 @@ func (opts *cmdOptions) parse() bool {
// Must run after pflag.Parse()
switch {
case *version:
fmt.Println(beszel.AppName+"-agent", beszel.Version)
return true
case *help || subcommand == "help":
pflag.Usage()
return true
@@ -137,38 +133,6 @@ func (opts *cmdOptions) getAddress() string {
return agent.GetAddress(opts.listen)
}
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
func handleFingerprint() {
subCmd := ""
if len(os.Args) > 2 {
subCmd = os.Args[2]
}
switch subCmd {
case "", "view":
dataDir, _ := agent.GetDataDir()
fp := agent.GetFingerprint(dataDir, "", "")
fmt.Println(fp)
case "help", "-h", "--help":
fmt.Print(fingerprintUsage())
case "reset":
dataDir, err := agent.GetDataDir()
if err != nil {
log.Fatal(err)
}
if err := agent.DeleteFingerprint(dataDir); err != nil {
log.Fatal(err)
}
fmt.Println("Fingerprint reset. A new one will be generated on next start.")
default:
log.Fatalf("Unknown command: %q\n\n%s", subCmd, fingerprintUsage())
}
}
func fingerprintUsage() string {
return fmt.Sprintf("Usage: %s fingerprint [view|reset]\n\nCommands:\n view Print fingerprint (default)\n reset Reset saved fingerprint\n", os.Args[0])
}
func main() {
var opts cmdOptions
subcommandHandled := opts.parse()

View File

@@ -1,7 +1,6 @@
package common
import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
@@ -35,14 +34,14 @@ type HubRequest[T any] struct {
// AgentResponse defines the structure for responses sent from agent to hub.
type AgentResponse struct {
Id *uint32 `cbor:"0,keyasint,omitempty"`
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"`
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
}
type FingerprintRequest struct {

View File

@@ -17,7 +17,7 @@ RUN rm -rf /tmp/*
# --------------------------
# Final image: default scratch-based agent
# --------------------------
FROM alpine:3.23
FROM alpine:3.22
COPY --from=builder /agent /agent
RUN apk add --no-cache smartmontools

View File

@@ -16,7 +16,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
# Final image
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
# --------------------------
FROM alpine:3.23
FROM alpine:3.22
COPY --from=builder /agent /agent

View File

@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:bookworm AS builder
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
@@ -10,7 +10,7 @@ COPY . ./
# Build
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
# --------------------------
# Smartmontools builder stage

View File

@@ -129,12 +129,11 @@ var DockerHealthStrings = map[string]DockerHealth{
// Docker container stats
type Stats struct {
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
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"`
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`

View File

@@ -130,23 +130,10 @@ type SummaryInfo struct {
}
type AtaSmartAttributes struct {
// Revision int `json:"revision"`
Table []AtaSmartAttribute `json:"table"`
}
type AtaDeviceStatistics struct {
Pages []AtaDeviceStatisticsPage `json:"pages"`
}
type AtaDeviceStatisticsPage struct {
Number uint8 `json:"number"`
Table []AtaDeviceStatisticsEntry `json:"table"`
}
type AtaDeviceStatisticsEntry struct {
Name string `json:"name"`
Value *uint64 `json:"value,omitempty"`
}
type AtaSmartAttribute struct {
ID uint16 `json:"id"`
Name string `json:"name"`
@@ -356,8 +343,7 @@ type SmartInfoForSata struct {
SmartStatus SmartStatusInfo `json:"smart_status"`
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics AtaDeviceStatistics `json:"ata_device_statistics"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
// PowerCycleCount uint16 `json:"power_cycle_count"`
Temperature TemperatureInfo `json:"temperature"`

View File

@@ -27,8 +27,8 @@ type Stats struct {
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,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
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"`

View File

@@ -11,7 +11,6 @@ import (
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@@ -346,32 +345,5 @@ func archiveSuffix(binaryName, goos, goarch string) string {
if goos == "windows" {
return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
}
// Use glibc build for agent on glibc systems (includes NVML support via purego)
if binaryName == "beszel-agent" && goos == "linux" && goarch == "amd64" && isGlibc() {
return fmt.Sprintf("%s_%s_%s_glibc.tar.gz", binaryName, goos, goarch)
}
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
}
func isGlibc() bool {
for _, path := range []string{
"/lib64/ld-linux-x86-64.so.2", // common on many distros
"/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", // Debian/Ubuntu
"/lib/ld-linux-x86-64.so.2", // alternate
} {
if _, err := os.Stat(path); err == nil {
return true
}
}
// Fallback to ldd output when present (musl ldd reports musl, glibc reports GNU libc/glibc).
if lddPath, err := exec.LookPath("ldd"); err == nil {
out, err := exec.Command(lddPath, "--version").CombinedOutput()
if err == nil {
s := strings.ToLower(string(out))
if strings.Contains(s, "gnu libc") || strings.Contains(s, "glibc") {
return true
}
}
}
return false
}

View File

@@ -1,66 +0,0 @@
package ghupdate
import (
"fmt"
"os/exec"
"strings"
)
// HandleSELinuxContext restores or applies the correct SELinux label to the binary.
func HandleSELinuxContext(path string) error {
out, err := exec.Command("getenforce").Output()
if err != nil {
// SELinux not enabled or getenforce not available
return nil
}
state := strings.TrimSpace(string(out))
if state == "Disabled" {
return nil
}
ColorPrint(ColorYellow, "SELinux is enabled; applying context…")
// Try persistent context via semanage+restorecon
if success := trySemanageRestorecon(path); success {
return nil
}
// Fallback to temporary context via chcon
if chconPath, err := exec.LookPath("chcon"); err == nil {
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
return fmt.Errorf("chcon failed: %w", err)
}
return nil
}
return fmt.Errorf("no SELinux tools available (semanage/restorecon or chcon)")
}
// trySemanageRestorecon attempts to set persistent SELinux context using semanage and restorecon.
// Returns true if successful, false otherwise.
func trySemanageRestorecon(path string) bool {
semanagePath, err := exec.LookPath("semanage")
if err != nil {
return false
}
restoreconPath, err := exec.LookPath("restorecon")
if err != nil {
return false
}
// Try to add the fcontext rule; if it already exists, try to modify it
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
// Rule may already exist, try modify instead
if err := exec.Command(semanagePath, "fcontext", "-m", "-t", "bin_t", path).Run(); err != nil {
return false
}
}
// Apply the context with restorecon
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
return false
}
return true
}

View File

@@ -1,53 +0,0 @@
package ghupdate
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestHandleSELinuxContext_NoSELinux(t *testing.T) {
// Skip on SELinux systems - this test is for non-SELinux behavior
if _, err := exec.LookPath("getenforce"); err == nil {
t.Skip("skipping on SELinux-enabled system")
}
// On systems without SELinux, getenforce will fail and the function
// should return nil without error
tempFile := filepath.Join(t.TempDir(), "test-binary")
if err := os.WriteFile(tempFile, []byte("test"), 0755); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
err := HandleSELinuxContext(tempFile)
if err != nil {
t.Errorf("HandleSELinuxContext() on non-SELinux system returned error: %v", err)
}
}
func TestHandleSELinuxContext_InvalidPath(t *testing.T) {
// Skip on SELinux systems - this test is for non-SELinux behavior
if _, err := exec.LookPath("getenforce"); err == nil {
t.Skip("skipping on SELinux-enabled system")
}
// On non-SELinux systems, getenforce fails early so even invalid paths succeed
err := HandleSELinuxContext("/nonexistent/path/binary")
if err != nil {
t.Errorf("HandleSELinuxContext() with invalid path on non-SELinux system returned error: %v", err)
}
}
func TestTrySemanageRestorecon_NoTools(t *testing.T) {
// Skip if semanage is available (we don't want to modify system SELinux policy)
if _, err := exec.LookPath("semanage"); err == nil {
t.Skip("skipping on system with semanage available")
}
// Should return false when semanage is not available
result := trySemanageRestorecon("/some/path")
if result {
t.Error("trySemanageRestorecon() returned true when semanage is not available")
}
}

View File

@@ -66,15 +66,6 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
// Check if token is an active universal token
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
if !acr.isUniversalToken {
// Fallback: check for a permanent universal token stored in the DB
if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil {
if userID := rec.GetString("user"); userID != "" {
acr.userId = userID
acr.isUniversalToken = true
}
}
}
// Find matching fingerprint records for this token
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)

View File

@@ -1169,106 +1169,6 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
}
}
// TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB
// (universal_tokens collection) is accepted for agent self-registration even if it is not
// present in the in-memory universalTokenMap.
func TestPermanentUniversalTokenFromDB(t *testing.T) {
// Create hub and test app
hub, testApp, err := createTestHub(t)
require.NoError(t, err)
defer testApp.Cleanup()
// Get the hub's SSH key
hubSigner, err := hub.GetSSHKey("")
require.NoError(t, err)
goodPubKey := hubSigner.PublicKey()
// Create test user
userRecord, err := createTestUser(testApp)
require.NoError(t, err)
// Create a permanent universal token record in the DB (do NOT add it to universalTokenMap)
universalToken := "db-universal-token-123"
_, err = createTestRecord(testApp, "universal_tokens", map[string]any{
"user": userRecord.Id,
"token": universalToken,
})
require.NoError(t, err)
// Create HTTP server with the actual API route
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/beszel/agent-connect" {
acr := &agentConnectRequest{
hub: hub,
req: r,
res: w,
}
acr.agentConnect()
} else {
http.NotFound(w, r)
}
}))
defer ts.Close()
// Create and configure agent
agentDataDir := t.TempDir()
err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644)
require.NoError(t, err)
testAgent, err := agent.NewAgent(agentDataDir)
require.NoError(t, err)
// Set up environment variables for the agent
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
defer func() {
os.Unsetenv("BESZEL_AGENT_HUB_URL")
os.Unsetenv("BESZEL_AGENT_TOKEN")
}()
// Start agent in background
done := make(chan error, 1)
go func() {
serverOptions := agent.ServerOptions{
Network: "tcp",
Addr: "127.0.0.1:46050",
Keys: []ssh.PublicKey{goodPubKey},
}
done <- testAgent.Start(serverOptions)
}()
// Wait for connection result
maxWait := 2 * time.Second
time.Sleep(20 * time.Millisecond)
checkInterval := 20 * time.Millisecond
timeout := time.After(maxWait)
ticker := time.Tick(checkInterval)
connectionManager := testAgent.GetConnectionManager()
for {
select {
case <-timeout:
t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State)
case <-ticker:
if connectionManager.State == agent.WebSocketConnected {
// Success
goto verify
}
case err := <-done:
// If Start returns early, treat it as failure
if err != nil {
t.Fatalf("Agent failed to start/connect: %v", err)
}
}
}
verify:
// Verify that a system was created for the user (self-registration path)
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
require.NoError(t, err)
require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token")
}
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
func TestFindOrCreateSystemForToken(t *testing.T) {
hub, testApp, err := createTestHub(t)

View File

@@ -20,7 +20,6 @@ import (
"github.com/henrygd/beszel/internal/users"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
@@ -194,34 +193,7 @@ func setCollectionAuthSettings(app core.App) error {
}
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
containersCollection.ListRule = &containersListRule
if err := app.Save(containersCollection); err != nil {
return err
}
// allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set
// these collections all have a "system" relation field
systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"}
for _, collectionName := range systemRelatedCollections {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return err
}
collection.ListRule = &containersListRule
// set viewRule for collections that need it (system_details, smart_devices)
if collection.ViewRule != nil {
collection.ViewRule = &containersListRule
}
// set deleteRule for smart_devices (allows user to dismiss disk warnings)
if collectionName == "smart_devices" {
deleteRule := containersListRule + " && @request.auth.role != \"readonly\""
collection.DeleteRule = &deleteRule
}
if err := app.Save(collection); err != nil {
return err
}
}
return nil
return app.Save(containersCollection)
}
// registerCronJobs sets up scheduled tasks
@@ -316,90 +288,24 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
response := map[string]any{"token": token}
// Token is considered active only if it belongs to the current user.
activeUser, ok := tokenMap.GetOk(token)
active := ok && activeUser == userID
response := map[string]any{"token": token, "active": active, "permanent": false}
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)
}

View File

@@ -378,18 +378,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
ExpectedContent: []string{"active", "token"},
TestAppFactory: testAppFactory,
},
{

View File

@@ -13,11 +13,9 @@ import (
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/transport"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
@@ -25,30 +23,28 @@ import (
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
sshTransport *transport.SSHTransport // SSH transport for requests
data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
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
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -317,11 +313,7 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
params["health"+suffix] = container.Health
params["cpu"+suffix] = container.Cpu
params["memory"+suffix] = container.Mem
netBytes := container.Bandwidth[0] + container.Bandwidth[1]
if netBytes == 0 {
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
}
params["net"+suffix] = netBytes
params["net"+suffix] = container.NetworkSent + container.NetworkRecv
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
@@ -367,78 +359,8 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
return sys.ctx, sys.cancel
}
// request sends a request to the agent, trying WebSocket first, then SSH.
// This is the unified request method that uses the transport abstraction.
func (sys *System) request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
// Try WebSocket first
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
if err := wsTransport.Request(ctx, action, req, dest); err == nil {
return nil
} else if !shouldFallbackToSSH(err) {
return err
} else if shouldCloseWebSocket(err) {
sys.closeWebSocketConnection()
}
}
// Fall back to SSH if WebSocket fails
if err := sys.ensureSSHTransport(); err != nil {
return err
}
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
// Keep legacy SSH client/version fields in sync for other code paths.
if sys.sshTransport != nil {
sys.client = sys.sshTransport.GetClient()
sys.agentVersion = sys.sshTransport.GetAgentVersion()
}
return err
}
func shouldFallbackToSSH(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
if errors.Is(err, gws.ErrConnClosed) {
return true
}
return errors.Is(err, transport.ErrWebSocketNotConnected)
}
func shouldCloseWebSocket(err error) bool {
if err == nil {
return false
}
return errors.Is(err, gws.ErrConnClosed) || errors.Is(err, transport.ErrWebSocketNotConnected)
}
// ensureSSHTransport ensures the SSH transport is initialized and connected.
func (sys *System) ensureSSHTransport() error {
if sys.sshTransport == nil {
if sys.manager.sshConfig == nil {
if err := sys.manager.createSSHClientConfig(); err != nil {
return err
}
}
sys.sshTransport = transport.NewSSHTransport(transport.SSHTransportConfig{
Host: sys.Host,
Port: sys.Port,
Config: sys.manager.sshConfig,
Timeout: 4 * time.Second,
})
}
// Sync client state with transport
if sys.client != nil {
sys.sshTransport.SetClient(sys.client)
sys.sshTransport.SetAgentVersion(sys.agentVersion)
}
return nil
}
// fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available.
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
@@ -464,47 +386,112 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
if err != nil {
return nil, err
}
return sys.data, nil
}
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
var result string
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: action, Data: requestData}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
err = cbor.NewDecoder(stdout).Decode(&resp)
if err != nil {
return false, err
}
if resp.String == nil {
return false, errors.New(errorMsg)
}
result = *resp.String
return false, nil
})
return result, err
}
// FetchContainerInfoFromAgent fetches container info from the agent
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result)
return result, err
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerInfo(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
// FetchContainerLogsFromAgent fetches container logs from the agent
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result)
return result, err
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerLogs(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result systemd.ServiceDetails
err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result)
return result, err
}
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
}
var result systemd.ServiceDetails
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err
}
_ = stdin.Close()
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return false, err
}
if resp.ServiceInfo == nil {
if resp.Error != "" {
return false, errors.New(resp.Error)
}
return false, errors.New("no systemd info in response")
}
result = resp.ServiceInfo
return false, nil
})
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err
}
@@ -669,9 +656,6 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
// closeSSHConnection closes the SSH connection but keeps the system in the manager
func (sys *System) closeSSHConnection() {
if sys.sshTransport != nil {
sys.sshTransport.Close()
}
if sys.client != nil {
sys.client.Close()
sys.client = nil

View File

@@ -1,14 +1,54 @@
package systems
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestSmartData(ctx)
}
// fetch via SSH
var result map[string]smart.SmartData
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: common.GetSmartData}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return false, err
}
result = resp.SmartData
return false, nil
})
return result, err
}
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
func (sys *System) FetchAndSaveSmartDevices() error {
smartData, err := sys.FetchSmartDataFromAgent()

View File

@@ -1,227 +0,0 @@
package transport
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"golang.org/x/crypto/ssh"
)
// SSHTransport implements Transport over SSH connections.
type SSHTransport struct {
client *ssh.Client
config *ssh.ClientConfig
host string
port string
agentVersion semver.Version
timeout time.Duration
}
// SSHTransportConfig holds configuration for creating an SSH transport.
type SSHTransportConfig struct {
Host string
Port string
Config *ssh.ClientConfig
AgentVersion semver.Version
Timeout time.Duration
}
// NewSSHTransport creates a new SSH transport with the given configuration.
func NewSSHTransport(cfg SSHTransportConfig) *SSHTransport {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 4 * time.Second
}
return &SSHTransport{
config: cfg.Config,
host: cfg.Host,
port: cfg.Port,
agentVersion: cfg.AgentVersion,
timeout: timeout,
}
}
// SetClient sets the SSH client for reuse across requests.
func (t *SSHTransport) SetClient(client *ssh.Client) {
t.client = client
}
// SetAgentVersion sets the agent version (extracted from SSH handshake).
func (t *SSHTransport) SetAgentVersion(version semver.Version) {
t.agentVersion = version
}
// GetClient returns the current SSH client (for connection management).
func (t *SSHTransport) GetClient() *ssh.Client {
return t.client
}
// GetAgentVersion returns the agent version.
func (t *SSHTransport) GetAgentVersion() semver.Version {
return t.agentVersion
}
// Request sends a request to the agent via SSH and unmarshals the response.
func (t *SSHTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
if t.client == nil {
if err := t.connect(); err != nil {
return err
}
}
session, err := t.createSessionWithTimeout(ctx)
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
// Send request
hubReq := common.HubRequest[any]{Action: action, Data: req}
if err := cbor.NewEncoder(stdin).Encode(hubReq); err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
stdin.Close()
// Read response
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if resp.Error != "" {
return errors.New(resp.Error)
}
if err := session.Wait(); err != nil {
return err
}
return UnmarshalResponse(resp, action, dest)
}
// IsConnected returns true if the SSH connection is active.
func (t *SSHTransport) IsConnected() bool {
return t.client != nil
}
// Close terminates the SSH connection.
func (t *SSHTransport) Close() {
if t.client != nil {
t.client.Close()
t.client = nil
}
}
// connect establishes a new SSH connection.
func (t *SSHTransport) connect() error {
if t.config == nil {
return errors.New("SSH config not set")
}
network := "tcp"
host := t.host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, t.port)
}
client, err := ssh.Dial(network, host, t.config)
if err != nil {
return err
}
t.client = client
// Extract agent version from server version string
t.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout.
func (t *SSHTransport) createSessionWithTimeout(ctx context.Context) (*ssh.Session, error) {
if t.client == nil {
return nil, errors.New("client not initialized")
}
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
session, err := t.client.NewSession()
if err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, errors.New("timeout creating session")
}
}
// extractAgentVersion extracts the beszel version from SSH server version string.
func extractAgentVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// RequestWithRetry sends a request with automatic retry on connection failures.
func (t *SSHTransport) RequestWithRetry(ctx context.Context, action common.WebSocketAction, req any, dest any, retries int) error {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
err := t.Request(ctx, action, req, dest)
if err == nil {
return nil
}
lastErr = err
// Check if it's a connection error that warrants a retry
if isConnectionError(err) && attempt < retries {
t.Close()
continue
}
return err
}
return lastErr
}
// isConnectionError checks if an error indicates a connection problem.
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "closed") ||
errors.Is(err, io.EOF)
}

View File

@@ -1,112 +0,0 @@
// Package transport provides a unified abstraction for hub-agent communication
// over different transports (WebSocket, SSH).
package transport
import (
"context"
"errors"
"fmt"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// Transport defines the interface for hub-agent communication.
// Both WebSocket and SSH transports implement this interface.
type Transport interface {
// Request sends a request to the agent and unmarshals the response into dest.
// The dest parameter should be a pointer to the expected response type.
Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error
// IsConnected returns true if the transport connection is active.
IsConnected() bool
// Close terminates the transport connection.
Close()
}
// UnmarshalResponse unmarshals an AgentResponse into the destination type.
// It first checks the generic Data field (0.19+ agents), then falls back
// to legacy typed fields for backward compatibility with 0.18.0 agents.
func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
if dest == nil {
return errors.New("nil destination")
}
// Try generic Data field first (0.19+)
if len(resp.Data) > 0 {
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
}
return nil
}
// Fall back to legacy typed fields for older agents/hubs.
return unmarshalLegacyResponse(resp, action, dest)
}
// unmarshalLegacyResponse handles legacy responses that use typed fields.
func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
switch action {
case common.GetData:
d, ok := dest.(*system.CombinedData)
if !ok {
return fmt.Errorf("unexpected dest type for GetData: %T", dest)
}
if resp.SystemData == nil {
return errors.New("no system data in response")
}
*d = *resp.SystemData
return nil
case common.CheckFingerprint:
d, ok := dest.(*common.FingerprintResponse)
if !ok {
return fmt.Errorf("unexpected dest type for CheckFingerprint: %T", dest)
}
if resp.Fingerprint == nil {
return errors.New("no fingerprint in response")
}
*d = *resp.Fingerprint
return nil
case common.GetContainerLogs:
d, ok := dest.(*string)
if !ok {
return fmt.Errorf("unexpected dest type for GetContainerLogs: %T", dest)
}
if resp.String == nil {
return errors.New("no logs in response")
}
*d = *resp.String
return nil
case common.GetContainerInfo:
d, ok := dest.(*string)
if !ok {
return fmt.Errorf("unexpected dest type for GetContainerInfo: %T", dest)
}
if resp.String == nil {
return errors.New("no info in response")
}
*d = *resp.String
return nil
case common.GetSmartData:
d, ok := dest.(*map[string]smart.SmartData)
if !ok {
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
}
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case common.GetSystemdInfo:
d, ok := dest.(*systemd.ServiceDetails)
if !ok {
return fmt.Errorf("unexpected dest type for GetSystemdInfo: %T", dest)
}
if resp.ServiceInfo == nil {
return errors.New("no systemd info in response")
}
*d = resp.ServiceInfo
return nil
}
return fmt.Errorf("unsupported action: %d", action)
}

View File

@@ -1,74 +0,0 @@
package transport
import (
"context"
"errors"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/ws"
)
// ErrWebSocketNotConnected indicates a WebSocket transport is not currently connected.
var ErrWebSocketNotConnected = errors.New("websocket not connected")
// WebSocketTransport implements Transport over WebSocket connections.
type WebSocketTransport struct {
wsConn *ws.WsConn
}
// NewWebSocketTransport creates a new WebSocket transport wrapper.
func NewWebSocketTransport(wsConn *ws.WsConn) *WebSocketTransport {
return &WebSocketTransport{wsConn: wsConn}
}
// Request sends a request to the agent via WebSocket and unmarshals the response.
func (t *WebSocketTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
if !t.IsConnected() {
return ErrWebSocketNotConnected
}
pendingReq, err := t.wsConn.SendRequest(ctx, action, req)
if err != nil {
return err
}
// Wait for response
select {
case message := <-pendingReq.ResponseCh:
defer message.Close()
defer pendingReq.Cancel()
// Legacy agents (< MinVersionAgentResponse) respond with a raw payload instead of an AgentResponse wrapper.
if t.wsConn.AgentVersion().LT(beszel.MinVersionAgentResponse) {
return cbor.Unmarshal(message.Data.Bytes(), dest)
}
var agentResponse common.AgentResponse
if err := cbor.Unmarshal(message.Data.Bytes(), &agentResponse); err != nil {
return err
}
if agentResponse.Error != "" {
return errors.New(agentResponse.Error)
}
return UnmarshalResponse(agentResponse, action, dest)
case <-pendingReq.Context.Done():
return pendingReq.Context.Err()
}
}
// IsConnected returns true if the WebSocket connection is active.
func (t *WebSocketTransport) IsConnected() bool {
return t.wsConn != nil && t.wsConn.IsConnected()
}
// Close terminates the WebSocket connection.
func (t *WebSocketTransport) Close() {
if t.wsConn != nil {
t.wsConn.Close(nil)
}
}

View File

@@ -45,11 +45,6 @@ func Update(cmd *cobra.Command, _ []string) {
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
}
// Fix SELinux context if necessary
if err := ghupdate.HandleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
}
// Try to restart the service if it's running
restartService()
}

View File

@@ -6,12 +6,14 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
// ResponseHandler defines interface for handling agent responses.
// This is used by handleAgentRequest for legacy response handling.
// ResponseHandler defines interface for handling agent responses
type ResponseHandler interface {
Handle(agentResponse common.AgentResponse) error
HandleLegacy(rawData []byte) error
@@ -25,7 +27,167 @@ func (h *BaseHandler) HandleLegacy(rawData []byte) error {
}
////////////////////////////////////////////////////////////////////////////
// Fingerprint handling (used for WebSocket authentication)
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// systemDataHandler implements ResponseHandler for system data requests
type systemDataHandler struct {
data *system.CombinedData
}
func (h *systemDataHandler) HandleLegacy(rawData []byte) error {
return cbor.Unmarshal(rawData, h.data)
}
func (h *systemDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SystemData != nil {
*h.data = *agentResponse.SystemData
}
return nil
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedData, options common.DataRequestOptions) error {
if !ws.IsConnected() {
return gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetData, options)
if err != nil {
return err
}
handler := &systemDataHandler{data: data}
return ws.handleAgentRequest(req, handler)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// stringResponseHandler is a generic handler for string responses from agents
type stringResponseHandler struct {
BaseHandler
value string
errorMsg string
}
func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.String == nil {
return errors.New(h.errorMsg)
}
h.value = *agentResponse.String
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket
func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
if !ws.IsConnected() {
return "", gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, action, requestData)
if err != nil {
return "", err
}
handler := &stringResponseHandler{errorMsg: errorMsg}
if err := ws.handleAgentRequest(req, handler); err != nil {
return "", err
}
return handler.value, nil
}
// RequestContainerLogs requests logs for a specific container via WebSocket.
func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// RequestContainerInfo requests information about a specific container via WebSocket.
func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
if !ws.IsConnected() {
return nil, gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
if err != nil {
return nil, err
}
var result systemd.ServiceDetails
handler := &systemdInfoHandler{result: &result}
if err := ws.handleAgentRequest(req, handler); err != nil {
return nil, err
}
return result, nil
}
// systemdInfoHandler parses ServiceDetails from AgentResponse
type systemdInfoHandler struct {
BaseHandler
result *systemd.ServiceDetails
}
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.ServiceInfo == nil {
return errors.New("no systemd info in response")
}
*h.result = agentResponse.ServiceInfo
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// RequestSmartData requests SMART data via WebSocket.
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, error) {
if !ws.IsConnected() {
return nil, gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetSmartData, nil)
if err != nil {
return nil, err
}
var result map[string]smart.SmartData
handler := ResponseHandler(&smartDataHandler{result: &result})
if err := ws.handleAgentRequest(req, handler); err != nil {
return nil, err
}
return result, nil
}
// smartDataHandler parses SMART data map from AgentResponse
type smartDataHandler struct {
BaseHandler
result *map[string]smart.SmartData
}
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SmartData == nil {
return errors.New("no SMART data in response")
}
*h.result = agentResponse.SmartData
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// fingerprintHandler implements ResponseHandler for fingerprint requests

View File

@@ -0,0 +1,75 @@
//go:build testing
package ws
import (
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
func TestSystemdInfoHandlerSuccess(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test successful handling with valid ServiceInfo
testDetails := systemd.ServiceDetails{
"Id": "nginx.service",
"ActiveState": "active",
"SubState": "running",
"Description": "A high performance web server",
"ExecMainPID": 1234,
"MemoryCurrent": 1024000,
}
response := common.AgentResponse{
ServiceInfo: testDetails,
}
err := handler.Handle(response)
assert.NoError(t, err)
assert.Equal(t, testDetails, *handler.result)
}
func TestSystemdInfoHandlerError(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test error handling when ServiceInfo is nil
response := common.AgentResponse{
ServiceInfo: nil,
Error: "service not found",
}
err := handler.Handle(response)
assert.Error(t, err)
assert.Equal(t, "no systemd info in response", err.Error())
}
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test with completely empty response
response := common.AgentResponse{}
err := handler.Handle(response)
assert.Error(t, err)
assert.Equal(t, "no systemd info in response", err.Error())
}
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test that legacy format is not supported
err := handler.HandleLegacy([]byte("some data"))
assert.Error(t, err)
assert.Equal(t, "legacy format not supported", err.Error())
}

View File

@@ -45,15 +45,7 @@ func NewRequestManager(conn *gws.Conn) *RequestManager {
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
reqID := RequestID(rm.nextID.Add(1))
// Respect any caller-provided deadline. If none is set, apply a reasonable default
// so pending requests don't live forever if the agent never responds.
reqCtx := ctx
var cancel context.CancelFunc
if _, hasDeadline := ctx.Deadline(); hasDeadline {
reqCtx, cancel = context.WithCancel(ctx)
} else {
reqCtx, cancel = context.WithTimeout(ctx, 5*time.Second)
}
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
req := &PendingRequest{
ID: reqID,
@@ -108,11 +100,6 @@ func (rm *RequestManager) handleResponse(message *gws.Message) {
return
}
if response.Id == nil {
rm.routeLegacyResponse(message)
return
}
reqID := RequestID(*response.Id)
rm.RLock()

View File

@@ -1,7 +1,6 @@
package ws
import (
"context"
"errors"
"time"
"weak"
@@ -162,14 +161,3 @@ func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandle
func (ws *WsConn) IsConnected() bool {
return ws.conn != nil
}
// AgentVersion returns the connected agent's version (as reported during handshake).
func (ws *WsConn) AgentVersion() semver.Version {
return ws.agentVersion
}
// SendRequest sends a request to the agent and returns a pending request handle.
// This is used by the transport layer to send requests.
func (ws *WsConn) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
return ws.requestManager.SendRequest(ctx, action, data)
}

View File

@@ -184,18 +184,14 @@ func TestCommonActions(t *testing.T) {
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
}
func TestFingerprintHandler(t *testing.T) {
var result common.FingerprintResponse
h := &fingerprintHandler{result: &result}
func TestLogsHandler(t *testing.T) {
h := &stringResponseHandler{errorMsg: "no logs in response"}
resp := common.AgentResponse{Fingerprint: &common.FingerprintResponse{
Fingerprint: "test-fingerprint",
Hostname: "test-host",
}}
logValue := "test logs"
resp := common.AgentResponse{String: &logValue}
err := h.Handle(resp)
assert.NoError(t, err)
assert.Equal(t, "test-fingerprint", result.Fingerprint)
assert.Equal(t, "test-host", result.Hostname)
assert.Equal(t, logValue, h.value)
}
// TestHandler tests that we can create a Handler

View File

@@ -1617,74 +1617,6 @@ func init() {
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 10,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2375276105",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1597481275",
"max": 0,
"min": 0,
"name": "token",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3383022248",
"indexes": [
"CREATE INDEX ` + "`" + `idx_iaD9Y2Lgbl` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `token` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_wdR0A4PbRG` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `user` + "`" + `)"
],
"listRule": null,
"name": "universal_tokens",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
]`

View File

@@ -190,8 +190,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
id := record.Id
// clear global statsRecord for reuse
statsRecord.Stats = statsRecord.Stats[:0]
// reset tempStats each iteration to avoid omitzero fields retaining stale values
*stats = system.Stats{}
queryParams["id"] = id
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
@@ -446,11 +444,9 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
for i := range records {
id := records[i].Id
// clear global statsRecord for reuse
// clear global statsRecord and containerStats for reuse
statsRecord.Stats = statsRecord.Stats[:0]
// must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array
// which causes omitzero fields to inherit stale values from previous iterations
containerStats = nil
containerStats = containerStats[:0]
queryParams["id"] = id
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
@@ -465,24 +461,19 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
sentBytes := stat.Bandwidth[0]
recvBytes := stat.Bandwidth[1]
if sentBytes == 0 && recvBytes == 0 && (stat.NetworkSent != 0 || stat.NetworkRecv != 0) {
sentBytes = uint64(stat.NetworkSent * 1024 * 1024)
recvBytes = uint64(stat.NetworkRecv * 1024 * 1024)
}
sums[stat.Name].Bandwidth[0] += sentBytes
sums[stat.Name].Bandwidth[1] += recvBytes
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
}
}
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
Bandwidth: [2]uint64{uint64(float64(value.Bandwidth[0]) / count), uint64(float64(value.Bandwidth[1]) / count)},
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
})
}
return result

View File

@@ -14,7 +14,6 @@ export default defineConfig({
"he",
"hr",
"hu",
"id",
"it",
"ja",
"ko",

View File

@@ -1,12 +1,12 @@
{
"name": "beszel",
"version": "0.18.3",
"version": "0.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beszel",
"version": "0.18.3",
"version": "0.17.0",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
@@ -111,6 +111,7 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -1137,6 +1138,7 @@
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.20.12",
"@babel/runtime": "^7.20.13",
@@ -1290,6 +1292,7 @@
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@lingui/message-utils": "5.4.1"
@@ -3485,6 +3488,7 @@
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3495,6 +3499,7 @@
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -3699,6 +3704,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -5072,9 +5078,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.sortby": {
@@ -5316,9 +5322,9 @@
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5328,6 +5334,22 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"dev": true,
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moo": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
@@ -5371,6 +5393,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
@@ -5580,6 +5603,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5725,6 +5749,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz",
"integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5734,6 +5759,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6273,7 +6299,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.3",
@@ -6290,16 +6317,17 @@
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"dev": true,
"license": "BlueOak-1.0.0",
"license": "ISC",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
@@ -6394,6 +6422,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6628,6 +6657,7 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.18.3",
"version": "0.18.0-beta.1",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -77,4 +77,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -56,7 +56,7 @@ export const ActiveAlerts = () => {
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name()}
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (

View File

@@ -49,12 +49,10 @@ export function AddSystemButton({ className }: { className?: string }) {
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
<PlusIcon className="h-4 w-4 450:-ms-1" />
<span className="hidden 450:inline">
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</span>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</Button>
</DialogTrigger>
{opened.current && <SystemDialog setOpen={setOpen} />}

View File

@@ -2,14 +2,7 @@
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
pinnedAxisDomain,
xAxis,
} from "@/components/ui/chart"
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
import { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
@@ -38,23 +31,6 @@ export default memo(function ContainerChart({
const isNetChart = chartType === ChartType.Network
// Filter with set lookup
const filteredKeys = useMemo(() => {
if (!filter) {
return new Set<string>()
}
const filterTerms = filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
return new Set(
Object.keys(chartConfig).filter((key) => {
const keyLower = key.toLowerCase()
return !filterTerms.some((term) => keyLower.includes(term))
})
)
}, [chartConfig, filter])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
@@ -71,53 +47,27 @@ export default memo(function ContainerChart({
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart)
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`)
}
}
// tooltip formatter
if (isNetChart) {
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
if (record?.b?.length && record.b.length >= 2) {
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
}
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
}
const formatRxTx = (recv: number, sent: number) => {
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
return (
<span className="flex">
{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(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
}
obj.toolTipFormatter = (item: any, key: string) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") {
continue
}
// Skip filtered out containers
if (filteredKeys.has(containerKey)) {
continue
}
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
totalSent += sent
totalRecv += recv
}
return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
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(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
} catch (e) {
return null
}
@@ -132,20 +82,24 @@ export default memo(function ContainerChart({
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => {
const payload = data[key]
if (!payload) {
return null
}
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
return sent + recv
}
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
return obj
}, [filteredKeys])
}, [])
// Filter with set lookup
const filteredKeys = useMemo(() => {
if (!filter) {
return new Set<string>()
}
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
return new Set(Object.keys(chartConfig).filter((key) => {
const keyLower = key.toLowerCase()
return !filterTerms.some(term => keyLower.includes(term))
}))
}, [chartConfig, filter])
// console.log('rendered at', new Date())

View File

@@ -50,12 +50,10 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
const currentCpu = totalUsage.cpu.get(containerName) ?? 0
const currentMemory = totalUsage.memory.get(containerName) ?? 0
const currentNetwork = totalUsage.network.get(containerName) ?? 0
const sentBytes = containerStats.b?.[0] ?? (containerStats.ns ?? 0) * 1024 * 1024
const recvBytes = containerStats.b?.[1] ?? (containerStats.nr ?? 0) * 1024 * 1024
totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0))
totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0))
totalUsage.network.set(containerName, currentNetwork + sentBytes + recvBytes)
totalUsage.network.set(containerName, currentNetwork + (containerStats.nr ?? 0) + (containerStats.ns ?? 0))
}
}

View File

@@ -20,19 +20,11 @@ import { $allSystemsById } from "@/lib/stores"
import { useStore } from "@nanostores/react"
// Unit names and their corresponding number of seconds for converting docker status strings
const unitSeconds = [
["s", 1],
["mi", 60],
["h", 3600],
["d", 86400],
["w", 604800],
["mo", 2592000],
] as const
const unitSeconds = [["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000]] as const
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
function getStatusValue(status: string): number {
const [_, num, unit] = status.split(" ")
// Docker uses "a" or "an" instead of "1" for singular units (e.g., "Up a minute", "Up an hour")
const numValue = num === "a" || num === "an" ? 1 : Number(num)
const numValue = Number(num)
for (const [unitName, value] of unitSeconds) {
if (unit.startsWith(unitName)) {
return numValue * value
@@ -105,7 +97,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
const formatted = formatBytes(val, true, undefined, false)
const formatted = formatBytes(val, true, undefined, true)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
@@ -121,14 +113,13 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
return (
<Badge variant="outline" className="dark:border-white/12">
<span
className={cn("size-2 me-1.5 rounded-full", {
"bg-green-500": healthValue === ContainerHealth.Healthy,
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
"bg-yellow-500": healthValue === ContainerHealth.Starting,
"bg-zinc-500": healthValue === ContainerHealth.None,
})}
></span>
<span className={cn("size-2 me-1.5 rounded-full", {
"bg-green-500": healthValue === ContainerHealth.Healthy,
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
"bg-yellow-500": healthValue === ContainerHealth.Starting,
"bg-zinc-500": healthValue === ContainerHealth.None,
})}>
</span>
{healthStatus}
</Badge>
)
@@ -138,9 +129,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />
),
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
},
@@ -162,27 +151,20 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
return (
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
)
},
},
]
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<ContainerRecord>
name: string
Icon: React.ElementType
}) {
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
@@ -191,4 +173,4 @@ function HeaderButton({
<ArrowUpDownIcon className="size-4" />
</Button>
)
}
}

View File

@@ -57,13 +57,8 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
.then(
({ items }) => {
if (items.length === 0) {
setData((curItems) => {
if (systemId) {
return curItems?.filter((item) => item.system !== systemId) ?? []
}
return []
})
return
setData([]);
return;
}
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
@@ -285,7 +280,7 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
])
try {
info = JSON.stringify(JSON.parse(info), null, 2)
} catch (_) { }
} catch (_) {}
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
} catch (error) {
console.error(error)
@@ -342,7 +337,7 @@ function ContainerSheet({
setLogsDisplay("")
setInfoDisplay("")
if (!container) return
; (async () => {
;(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)

View File

@@ -1,32 +1,24 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react/macro"
import { LanguagesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
export function LangToggle() {
const { i18n } = useLingui()
const LangTrans = <Trans>Language</Trans>
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">{LangTrans}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{LangTrans}</TooltipContent>
</Tooltip>
<DropdownMenuTrigger asChild>
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">Language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
{languages.map(({ lang, label, e }) => (
<DropdownMenuItem
key={lang}
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}

View File

@@ -25,13 +25,13 @@ const passwordSchema = v.pipe(
)
const LoginSchema = v.looseObject({
website: honeypot,
name: honeypot,
email: emailSchema,
password: passwordSchema,
})
const RegisterSchema = v.looseObject({
website: honeypot,
name: honeypot,
email: emailSchema,
password: passwordSchema,
passwordConfirm: passwordSchema,
@@ -248,19 +248,8 @@ export function UserAuthForm({
)}
<div className="sr-only">
{/* honeypot */}
<label htmlFor="website"></label>
<input
id="website"
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
data-1p-ignore
data-lpignore="true"
data-bwignore
data-form-type="other"
data-protonpass-ignore
/>
<label htmlFor="name"></label>
<input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (

View File

@@ -2,28 +2,19 @@ import { t } from "@lingui/core/macro"
import { MoonStarIcon, SunIcon } from "lucide-react"
import { useTheme } from "@/components/theme-provider"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { Trans } from "@lingui/react/macro"
export function ModeToggle() {
const { theme, setTheme } = useTheme()
return (
<Tooltip>
<TooltipTrigger>
<Button
variant={"ghost"}
size="icon"
aria-label={t`Toggle theme`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans>Toggle theme</Trans>
</TooltipContent>
</Tooltip>
<Button
variant={"ghost"}
size="icon"
aria-label={t`Toggle theme`}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
</Button>
)
}

View File

@@ -30,7 +30,7 @@ import { LangToggle } from "./lang-toggle"
import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
import { t } from "@lingui/core/macro"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -49,50 +49,30 @@ export default function Navbar() {
</Link>
<SearchButton />
{/** biome-ignore lint/a11y/noStaticElementInteractions: ignore */}
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "containers")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Containers"
>
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>All Containers</Trans>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "smart")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="S.M.A.R.T."
>
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
</TooltipTrigger>
<TooltipContent>S.M.A.R.T.</TooltipContent>
</Tooltip>
<Link
href={getPagePath($router, "containers")}
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="Containers"
>
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<Link
href={getPagePath($router, "smart")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="S.M.A.R.T."
>
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<LangToggle />
<ModeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Link
href={getPagePath($router, "settings", { name: "general" })}
aria-label="Settings"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
>
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
</Link>
</TooltipTrigger>
<TooltipContent>
<Trans>Settings</Trans>
</TooltipContent>
</Tooltip>
<Link
href={getPagePath($router, "settings", { name: "general" })}
aria-label="Settings"
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
>
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
@@ -149,21 +129,21 @@ export default function Navbar() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AddSystemButton className="ms-2" />
<AddSystemButton className="ms-2 hidden 450:flex" />
</div>
</div>
)
}
const Kbd = ({ children }: { children: React.ReactNode }) => (
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
{children}
</kbd>
)
function SearchButton() {
const [open, setOpen] = useState(false)
const Kbd = ({ children }: { children: React.ReactNode }) => (
<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100">
{children}
</kbd>
)
return (
<>
<Button

View File

@@ -68,10 +68,10 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
<SelectValue />
</SelectTrigger>
<SelectContent>
{languages.map(([lang, label, e]) => (
<SelectItem key={lang} value={lang}>
<span className="me-2.5">{e}</span>
{label}
{languages.map((lang) => (
<SelectItem key={lang.lang} value={lang.lang}>
<span className="me-2.5">{lang.e}</span>
{lang.label}
</SelectItem>
))}
</SelectContent>

View File

@@ -32,7 +32,6 @@ import {
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/use-toast"
import { isReadOnlyUser, pb } from "@/lib/api"
@@ -138,23 +137,21 @@ const SectionUniversalToken = memo(() => {
const [token, setToken] = useState("")
const [isLoading, setIsLoading] = useState(true)
const [checked, setChecked] = useState(false)
const [isPermanent, setIsPermanent] = useState(false)
async function updateToken(enable: number = -1, permanent: number = -1) {
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,
permanent,
},
})
setToken(data.token)
setChecked(data.active)
setIsPermanent(!!data.permanent)
setIsLoading(false)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
useEffect(() => {
updateToken()
}, [])
@@ -165,64 +162,30 @@ const SectionUniversalToken = memo(() => {
<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.</Trans>
<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="mt-3 border rounded-md px-4 py-3 max-w-full">
<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 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4 min-w-0">
<Switch
checked={checked}
onCheckedChange={(checked) => {
// Keep current permanence preference when enabling/disabling
updateToken(checked ? 1 : 0, isPermanent ? 1 : 0)
}}
/>
<div className="min-w-0 flex-1 overflow-auto">
<span
className={cn(
"text-sm text-primary opacity-60 transition-opacity",
checked ? "opacity-100" : "select-none"
)}
>
{token}
</span>
</div>
<ActionsButtonUniversalToken token={token} checked={checked} />
</div>
{checked && (
<div className="border-t pt-3">
<div className="text-sm font-medium">
<Trans>Persistence</Trans>
</div>
<Tabs
value={isPermanent ? "permanent" : "ephemeral"}
onValueChange={(value) => updateToken(1, value === "permanent" ? 1 : 0)}
className="mt-2"
>
<TabsList>
<TabsTrigger className="xs:min-w-40" value="ephemeral">
<Trans>Ephemeral</Trans>
</TabsTrigger>
<TabsTrigger className="xs:min-w-40" value="permanent">
<Trans>Permanent</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="ephemeral" className="mt-3">
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Expires after one hour or on hub restart.</Trans>
</p>
</TabsContent>
<TabsContent value="permanent" className="mt-3">
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>Saved in the database and does not expire until you disable it.</Trans>
</p>
</TabsContent>
</Tabs>
</div>
)}
</div>
<>
<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>

View File

@@ -16,7 +16,7 @@ import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, SystemStatus, Unit } from "@/lib/enums"
import { ChartType, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n"
import {
$allSystemsById,
@@ -222,6 +222,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
@@ -259,6 +260,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}
}, [chartTime, system.id])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
@@ -298,6 +300,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}, [])
// get stats
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
if (!system.id || !chartTime || chartTime === "1m") {
return
@@ -363,8 +366,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
e.target instanceof HTMLTextAreaElement ||
e.shiftKey ||
e.ctrlKey ||
e.metaKey ||
e.altKey
e.metaKey
) {
return
}
@@ -401,32 +403,11 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const containerFilterBar = containerData.length ? <FilterBar /> : null
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
let hasGpuData = false
let hasGpuEnginesData = false
let hasGpuPowerData = false
if (lastGpus) {
// check if there are any GPUs at all
hasGpuData = Object.keys(lastGpus).length > 0
// check if there are any GPUs with engines or power data
for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) {
const gpus = systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (!hasGpuEnginesData && gpus[id].e !== undefined) {
hasGpuEnginesData = true
}
if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) {
hasGpuPowerData = true
}
if (hasGpuEnginesData && hasGpuPowerData) break
}
}
}
const isLinux = !(details?.os ?? system.info?.os)
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
const hasGpuData = lastGpuVals.length > 0
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
const isLinux = (details?.os ?? system.info?.os) === Os.Linux
const isPodman = details?.podman ?? system.info?.p ?? false
return (
@@ -737,65 +718,64 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<GpuEnginesChart chartData={chartData} />
</ChartCard>
)}
{lastGpus &&
Object.keys(lastGpus).map((id) => {
const gpu = lastGpus[id] as GPUData
return (
<div key={id} className="contents">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
return (
<div key={id} className="contents">
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<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>
{(gpu.mt ?? 0) > 0 && (
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
color: 1,
opacity: 0.35,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: 2,
opacity: 0.25,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
max={gpu.mt}
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>
{(gpu.mt ?? 0) > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: 2,
opacity: 0.25,
},
]}
max={gpu.mt}
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>
)
})}
)}
</div>
)
})}
</div>
)}
@@ -889,30 +869,16 @@ export default memo(function SystemDetail({ id }: { id: string }) {
})
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
const { gpuId, engines } = useMemo(() => {
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (gpus[id].e) {
return { gpuId: id, engines: Object.keys(gpus[id].e).sort() }
}
}
}
return { gpuId: null, engines: [] }
}, [chartData.systemStats])
if (!gpuId) {
return null
const dataPoints: DataPoint[] = []
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
for (const engine of engines) {
dataPoints.push({
label: engine,
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[0]?.e?.[engine] ?? 0,
color: `hsl(${140 + (((engines.indexOf(engine) * 360) / engines.length) % 360)}, 65%, 52%)`,
opacity: 0.35,
})
}
const dataPoints: DataPoint[] = engines.map((engine, i) => ({
label: engine,
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gpuId]?.e?.[engine] ?? 0,
color: `hsl(${140 + (((i * 360) / engines.length) % 360)}, 65%, 52%)`,
opacity: 0.35,
}))
return (
<LineChartDefault
legend={true}

View File

@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
@@ -135,41 +135,43 @@ export default function InfoBar({
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</span>
{translatedStatus}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
@@ -184,10 +186,12 @@ export default function InfoBar({
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
@@ -198,24 +202,26 @@ export default function InfoBar({
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>

View File

@@ -8,7 +8,6 @@ import type { ClassValue } from "clsx"
import {
ArrowUpDownIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CopyIcon,
CpuIcon,
HardDriveIcon,
@@ -35,7 +34,6 @@ import {
formatTemperature,
getMeterState,
parseSemVer,
secondsToString,
} from "@/lib/utils"
import { batteryStateTranslations } from "@/lib/i18n"
import type { SystemRecord } from "@/types"
@@ -130,32 +128,17 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
cell: (info) => {
const { name, id } = info.row.original
const longestName = useStore($longestSystemNameLen)
const linkUrl = getPagePath($router, "system", { id })
return (
<>
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1">
<IndicatorDot system={info.row.original} />
<Link
href={linkUrl}
tabIndex={-1}
className="truncate z-10 relative"
style={{ width: `${longestName / 1.05}ch` }}
onMouseEnter={(e) => {
// set title on hover if text is truncated to show full name
const a = e.currentTarget
if (a.scrollWidth > a.clientWidth) {
a.title = name
} else {
a.removeAttribute("title")
}
}}
>
{/* NOTE: change to 1 ch if switching to monospace font */}
<span className="truncate" style={{ width: `${longestName / 1.1}ch` }}>
{name}
</Link>
</span>
</span>
<Link
href={linkUrl}
href={getPagePath($router, "system", { id })}
className="inset-0 absolute size-full"
aria-label={name}
></Link>
@@ -304,12 +287,12 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
return null
}
const iconColor = pct < 10 ? "text-red-500" : pct < 25 ? "text-yellow-500" : "text-muted-foreground"
let Icon = PlugChargingIcon
let iconColor = "text-muted-foreground"
if (state !== BatteryState.Charging) {
if (pct < 25) {
iconColor = pct < 11 ? "text-red-500" : "text-yellow-500"
Icon = BatteryLowIcon
} else if (pct < 75) {
Icon = BatteryMediumIcon
@@ -375,29 +358,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
)
},
},
{
accessorFn: ({ info }) => info.u || undefined,
id: "uptime",
name: () => t`Uptime`,
size: 50,
Icon: ClockArrowUp,
header: sortableHeader,
cell(info) {
const uptime = info.getValue() as number
if (!uptime) {
return null
}
let formatted: string
if (uptime < 3600) {
formatted = secondsToString(uptime, "minute")
} else if (uptime < 360000) {
formatted = secondsToString(uptime, "hour")
} else {
formatted = secondsToString(uptime, "day")
}
return <span className="tabular-nums whitespace-nowrap">{formatted}</span>
},
},
{
accessorFn: ({ info }) => info.v,
id: "agent",
@@ -479,9 +439,9 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
const meterClass = cn(
"h-full",
(info.row.original.status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down
(threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down
)
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
@@ -593,7 +553,7 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas
return (
<span
className={cn("shrink-0 size-2 rounded-full", className)}
// style={{ marginBottom: "-1px" }}
// style={{ marginBottom: "-1px" }}
/>
)
}

View File

@@ -94,18 +94,18 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean
showTotal?: boolean
totalLabel?: React.ReactNode
}
React.ComponentProps<"div"> & {
hideLabel?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
unit?: string
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean
showTotal?: boolean
totalLabel?: React.ReactNode
}
>(
(
{
@@ -139,13 +139,10 @@ const ChartTooltipContent = React.forwardRef<
React.useMemo(() => {
if (filter) {
const filterTerms = filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
payload = payload?.filter((item) => {
const itemName = (item.name as string)?.toLowerCase()
return filterTerms.some((term) => itemName?.includes(term))
return filterTerms.some(term => itemName?.includes(term))
})
}
if (itemSorter) {
@@ -161,6 +158,7 @@ const ChartTooltipContent = React.forwardRef<
let totalValue = 0
let hasNumericValue = false
const aggregatedNestedValues: Record<string, number> = {}
for (const item of payload) {
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
@@ -168,6 +166,19 @@ const ChartTooltipContent = React.forwardRef<
totalValue += numericValue
hasNumericValue = true
}
if (content && item?.payload) {
const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
const nestedPayload = (item.payload as Record<string, unknown> | undefined)?.[payloadKey]
if (nestedPayload && typeof nestedPayload === "object") {
for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) {
if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue
}
}
}
}
}
if (!hasNumericValue) {
@@ -183,11 +194,24 @@ const ChartTooltipContent = React.forwardRef<
}
if (content) {
totalItem.payload = payload[0]?.payload
const basePayload =
payload[0]?.payload && typeof payload[0].payload === "object"
? { ...(payload[0].payload as Record<string, unknown>) }
: {}
totalItem.payload = {
...basePayload,
[totalKey]: aggregatedNestedValues,
}
}
if (typeof formatter === "function") {
return formatter(totalValue, totalName, totalItem, payload.length, totalItem.payload ?? payload[0]?.payload)
return formatter(
totalValue,
totalName,
totalItem,
payload.length,
totalItem.payload ?? payload[0]?.payload
)
}
if (content) {
@@ -319,11 +343,11 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
reverse?: boolean
}
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
reverse?: boolean
}
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
// const { config } = useChart()
@@ -433,16 +457,13 @@ export {
}
export function pinnedAxisDomain(): AxisDomain {
return [
0,
(dataMax: number) => {
if (dataMax > 10) {
return Math.round(dataMax)
}
if (dataMax > 1) {
return Math.round(dataMax / 0.1) * 0.1
}
return dataMax
},
]
}
return [0, (dataMax: number) => {
if (dataMax > 10) {
return Math.round(dataMax)
}
if (dataMax > 1) {
return Math.round(dataMax / 0.1) * 0.1
}
return dataMax
}]
}

View File

@@ -154,7 +154,7 @@ export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
<path d="M16 17H8V6h8m.7-2H15V2H9v2H7.3A1.3 1.3 0 0 0 6 5.3v15.4q.1 1.2 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3V5.3q-.1-1.2-1.3-1.3" />
<path d="M16 20H8V6h8m.67-2H15V2H9v2H7.33C6.6 4 6 4.6 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34c.74 0 1.33-.59 1.33-1.33V5.33C18 4.6 17.4 4 16.67 4M15 16H9v3h6zm0-4.5H9v3h6z" />
</svg>
)
}

View File

@@ -3,7 +3,7 @@ import { CopyIcon } from "lucide-react"
import { copyToClipboard } from "@/lib/utils"
import { Button } from "./button"
import { Input } from "./input"
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
return (
@@ -14,23 +14,25 @@ export function InputCopy({ value, id, name }: { value: string; id: string; name
"h-6 w-24 bg-linear-to-r rtl:bg-linear-to-l from-transparent to-background to-65% absolute top-2 end-1 pointer-events-none"
}
></div>
<Tooltip disableHoverableContent={true}>
<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 delayDuration={100} disableHoverableContent>
<Tooltip disableHoverableContent={true}>
<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

@@ -3,7 +3,7 @@ import type * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({ delayDuration = 50, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
}

View File

@@ -53,7 +53,7 @@ export function getLocale() {
}
locale = (locale || "en").split("-")[0]
// use en if locale is not in languages
if (!languages.some((l) => l[0] === locale)) {
if (!languages.some((l) => l.lang === locale)) {
locale = "en"
}
return locale

View File

@@ -1,32 +1,147 @@
export default [
["ar", "العربية", "🇵🇸"],
["bg", "Български", "🇧🇬"],
["cs", "Čeština", "🇨🇿"],
["da", "Dansk", "🇩🇰"],
["de", "Deutsch", "🇩🇪"],
["en", "English", "🇬🇧"],
["es", "Español", "🇪🇸"],
["fa", "فارسی", "🇮🇷"],
["fr", "Français", "🇫🇷"],
["he", "עברית", "🕎"],
["hr", "Hrvatski", "🇭🇷"],
["hu", "Magyar", "🇭🇺"],
["id", "Indonesia", "🇮🇩"],
["it", "Italiano", "🇮🇹"],
["ja", "日本語", "🇯🇵"],
["ko", "한국어", "🇰🇷"],
["nl", "Nederlands", "🇳🇱"],
["no", "Norsk", "🇳🇴"],
["pl", "Polski", "🇵🇱"],
["pt", "Português", "🇵🇹"],
["ru", "Русский", "🇷🇺"],
["sl", "Slovenščina", "🇸🇮"],
["sr", "Српски", "🇷🇸"],
["sv", "Svenska", "🇸🇪"],
["tr", "Türkçe", "🇹🇷"],
["uk", "Українська", "🇺🇦"],
["vi", "Tiếng Việt", "🇻🇳"],
["zh-CN", "简体中文", "🇨🇳"],
["zh-HK", "繁體中文", "🇭🇰"],
["zh", "繁體中文", "🇹🇼"],
{
lang: "ar",
label: "العربية",
e: "🇵🇸",
},
{
lang: "bg",
label: "Български",
e: "🇧🇬",
},
{
lang: "cs",
label: "Čeština",
e: "🇨🇿",
},
{
lang: "da",
label: "Dansk",
e: "🇩🇰",
},
{
lang: "de",
label: "Deutsch",
e: "🇩🇪",
},
{
lang: "en",
label: "English",
e: "🇺🇸",
},
{
lang: "es",
label: "Español",
e: "🇲🇽",
},
{
lang: "fa",
label: "فارسی",
e: "🇮🇷",
},
{
lang: "fr",
label: "Français",
e: "🇫🇷",
},
{
lang: "he",
label: "עברית",
e: "🕎",
},
{
lang: "hr",
label: "Hrvatski",
e: "🇭🇷",
},
{
lang: "hu",
label: "Magyar",
e: "🇭🇺",
},
{
lang: "it",
label: "Italiano",
e: "🇮🇹",
},
{
lang: "ja",
label: "日本語",
e: "🇯🇵",
},
{
lang: "ko",
label: "한국어",
e: "🇰🇷",
},
{
lang: "nl",
label: "Nederlands",
e: "🇳🇱",
},
{
lang: "no",
label: "Norsk",
e: "🇳🇴",
},
{
lang: "pl",
label: "Polski",
e: "🇵🇱",
},
{
lang: "pt",
label: "Português",
e: "🇧🇷",
},
{
lang: "ru",
label: "Русский",
e: "🇷🇺",
},
{
lang: "sl",
label: "Slovenščina",
e: "🇸🇮",
},
{
lang: "sr",
label: "Српски",
e: "🇷🇸",
},
{
lang: "sv",
label: "Svenska",
e: "🇸🇪",
},
{
lang: "tr",
label: "Türkçe",
e: "🇹🇷",
},
{
lang: "uk",
label: "Українська",
e: "🇺🇦",
},
{
lang: "vi",
label: "Tiếng Việt",
e: "🇻🇳",
},
{
lang: "zh-CN",
label: "简体中文",
e: "🇨🇳",
},
{
lang: "zh-HK",
label: "繁體中文",
e: "🇭🇰",
},
{
lang: "zh",
label: "繁體中文",
e: "🇹🇼",
},
] as const

View File

@@ -9,7 +9,7 @@ import {
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { getVisualStringWidth, updateFavicon } from "@/lib/utils"
import { updateFavicon } from "@/lib/utils"
import type { SystemRecord } from "@/types"
import { SystemStatus } from "./enums"
@@ -79,7 +79,7 @@ function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: System
// Update longest system name length
const longestName = $longestSystemNameLen.get()
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, changedSystem?.name.length || 0)
if (nameLen > longestName) {
$longestSystemNameLen.set(nameLen)
}

View File

@@ -27,7 +27,7 @@ export async function copyToClipboard(content: string) {
duration,
description: t`Copied to clipboard`,
})
} catch (_e) {
} catch (e) {
$copyContent.set(content)
}
}
@@ -316,7 +316,7 @@ export const getHostDisplayValue = (system: SystemRecord): string => system.host
export const generateToken = () => {
try {
return crypto?.randomUUID()
} catch (_e) {
} catch (e) {
return Array.from({ length: 2 }, () => (performance.now() * Math.random()).toString(16).replace(".", "-")).join("-")
}
}
@@ -429,30 +429,6 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
}) as T
}
/** Get the visual width of a string, accounting for full-width characters */
export function getVisualStringWidth(str: string): number {
let width = 0
for (const char of str) {
const code = char.codePointAt(0) || 0
// Hangul Jamo and Syllables are often slightly thinner than Hanzi/Kanji
if ((code >= 0x1100 && code <= 0x115f) || (code >= 0xac00 && code <= 0xd7af)) {
width += 1.8
continue
}
// Count CJK and other full-width characters as 2 units, others as 1
// Arabic and Cyrillic are counted as 1
const isFullWidth =
(code >= 0x2e80 && code <= 0x9fff) || // CJK Radicals, Symbols, and Ideographs
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
(code >= 0xfe30 && code <= 0xfe6f) || // CJK Compatibility Forms
(code >= 0xff00 && code <= 0xff60) || // Fullwidth Forms
(code >= 0xffe0 && code <= 0xffe6) || // Fullwidth Symbols
code > 0xffff // Emojis and other supplementary plane characters
width += isFullWidth ? 2 : 1
}
return width
}
/** Format seconds to hours, minutes, or seconds */
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2025-11-14 22:51\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
@@ -24,10 +24,6 @@ msgstr ""
msgid "{0} of {1} row(s) selected."
msgstr "تم تحديد {0} من {1} صف"
#: src/components/routes/system/info-bar.tsx
msgid "{cores, plural, one {# core} other {# cores}}"
msgstr "{cores, plural, one {# نواة} other {# نواة}}"
#: src/lib/utils.ts
msgid "{count, plural, one {{countString} day} other {{countString} days}}"
msgstr "{count, plural, one {{countString} يوم} other {{countString} أيام}}"
@@ -40,10 +36,6 @@ msgstr "{count, plural, one {{countString} ساعة} other {{countString} ساع
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr "{count, plural, one {{countString} دقيقة} few {{countString} دقائق} many {{countString} دقيقة} other {{countString} دقيقة}}"
#: src/components/routes/system/info-bar.tsx
msgid "{threads, plural, one {# thread} other {# threads}}"
msgstr "{threads, plural, one {# خيط} other {# خيط}}"
#: src/lib/utils.ts
msgid "1 hour"
msgstr "1 ساعة"
@@ -157,7 +149,6 @@ msgstr "التنبيهات"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "جميع الحاويات"
@@ -191,11 +182,6 @@ msgstr "متوسط"
msgid "Average CPU utilization of containers"
msgstr "متوسط استخدام وحدة المعالجة المركزية للحاويات"
#. placeholder {0}: alertData.unit
#: src/components/alerts/alerts-sheet.tsx
msgid "Average drops below <0>{value}{0}</0>"
msgstr "المتوسط ينخفض أقل من <0>{value}{0}</0>"
#. placeholder {0}: alertData.unit
#: src/components/alerts/alerts-sheet.tsx
msgid "Average exceeds <0>{value}{0}</0>"
@@ -228,13 +214,7 @@ msgstr "النسخ الاحتياطية"
msgid "Bandwidth"
msgstr "عرض النطاق الترددي"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "بطارية"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "البطارية"
@@ -250,13 +230,6 @@ msgstr "أصبح غير نشط"
msgid "Before"
msgstr "قبل"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
#: src/components/active-alerts.tsx
msgid "Below {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "أقل من {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
#: src/components/login/auth-form.tsx
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "يدعم بيزيل بروتوكول OpenID Connect والعديد من مزوّدي المصادقة عبر بروتوكول OAuth2."
@@ -595,7 +568,7 @@ msgstr "التوثيق"
#. Context: System is down
#: src/components/alerts-history-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "Down"
@@ -621,7 +594,7 @@ msgstr "تعديل"
#: src/components/add-system.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Edit {foo}"
msgstr "إضافة {foo}"
msgstr ""
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
@@ -655,10 +628,6 @@ msgstr "أدخل عنوان البريد الإشباكي..."
msgid "Enter your one-time password."
msgstr "أدخل كلمة المرور لمرة واحدة الخاصة بك."
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Ephemeral"
msgstr "مؤقت"
#: src/components/login/auth-form.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/settings/config-yaml.tsx
@@ -689,10 +658,6 @@ msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في
msgid "Exited active"
msgstr "خرج نشطًا"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Expires after one hour or on hub restart."
msgstr "ينتهي بعد ساعة واحدة أو عند إعادة تشغيل المحور."
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Export"
msgstr "تصدير"
@@ -838,7 +803,11 @@ msgstr "غير نشط"
msgid "Invalid email address."
msgstr "عنوان البريد الإشباكي غير صالح."
#: src/components/lang-toggle.tsx
#. Linux kernel
#: src/components/routes/system.tsx
msgid "Kernel"
msgstr "النواة"
#: src/components/routes/settings/general.tsx
msgid "Language"
msgstr "اللغة"
@@ -931,7 +900,6 @@ msgid "Max 1 min"
msgstr "الحد الأقصى دقيقة"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -1119,14 +1087,6 @@ msgstr "متوسط الاستخدام لكل نواة"
msgid "Percentage of time spent in each state"
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr "دائم"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
msgstr "الاستمرارية"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
@@ -1283,10 +1243,6 @@ msgstr "حفظ الإعدادات"
msgid "Save system"
msgstr "احفظ النظام"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "محفوظ في قاعدة البيانات ولا ينتهي حتى تقوم بتعطيله."
#: src/components/routes/settings/quiet-hours.tsx
msgid "Schedule"
msgstr "جدولة"
@@ -1337,7 +1293,6 @@ msgstr "تعيين عتبات النسبة المئوية لألوان العد
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
msgid "Settings"
@@ -1484,12 +1439,11 @@ msgstr "تنسيق الوقت"
msgid "To email(s)"
msgstr "إلى البريد الإشباكي"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
msgid "Toggle grid"
msgstr "تبديل الشبكة"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
msgstr "تبديل السمة"
@@ -1555,10 +1509,6 @@ msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل
msgid "Triggers when any sensor exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز أي مستشعر عتبة معينة"
#: src/lib/alerts.ts
msgid "Triggers when battery charge drops below a threshold"
msgstr "يتم التفعيل عندما تنخفض شحنة البطارية أقل من عتبة معينة"
#: src/lib/alerts.ts
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "يتم التفعيل عندما يتجاوز الجمع بين الصعود/الهبوط عتبة معينة"
@@ -1614,7 +1564,7 @@ msgid "Unlimited"
msgstr "غير محدود"
#. Context: System is up
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Up"
msgstr "قيد التشغيل"
@@ -1641,7 +1591,7 @@ msgstr "يتم التحديث كل 10 دقائق."
msgid "Upload"
msgstr "رفع"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system.tsx
msgid "Uptime"
msgstr "مدة التشغيل"
@@ -1713,8 +1663,8 @@ msgid "Webhook / Push notifications"
msgstr "إشعارات Webhook / Push"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "عند التفعيل، يسمح هذا الرمز المميز للوكلاء بالتسجيل الذاتي دون إنشاء نظام مسبق."
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
msgstr "عند التفعيل، يسمح هذا الرمز المميز للوكلاء بالتسجيل الذاتي دون إنشاء نظام مسبق. ينتهي بعد ساعة واحدة أو عند إعادة تشغيل المحور."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx

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