Compare commits

...
Author SHA1 Message Date
Jaypalsinh BaradandGitHub 734c966307 Implement container health alerts (#1679)
* Implement container health alerts, including a database migration for alert names and UI updates.

* Add bulk container alerts and improve alerting logic

* Implement container-specific alerts with new database collections, API endpoints, and UI components.
2026-09-02 13:19:40 -04:00
henrygd 79ca31d770 improve container network stats granularity by using bytes instead of MB
Changes container network statistics to use raw byte values instead of converting to megabytes agent-side, providing more accurate measurements for low-bandwidth containers. Maintains backward compatibility with older agents/hubs through fallback logic.

- Agent now sends Bandwidth field as [sent_bytes, recv_bytes] array
- Deprecated NetworkSent/NetworkRecv fields still populated for compatibility
- Hub and frontend fall back to deprecated fields when Bandwidth is zero
- Record averaging correctly handles both old and new formats
- TODO markers added for cleanup in version 0.19+
2026-01-31 14:05:55 -05:00
41f3705b6b update LibreHardwareMonitorLib to 0.9.5 (#1697)
fixes #1130

* add RuntimeIdentifier and AppendRuntimeIdentifierToOutputPath to beszel_lhm.csproj

* add more default sensor filters for LHM

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-30 19:23:56 -05:00
henrygd 20324763d2 remove stale systemd services from tracking after deletion (#1594) 2026-01-29 19:34:44 -05:00
henrygd 70f85f9590 fix SHARE_ALL_SYSTEMS for system_details, smart_devices, and systemd_services (#1660) 2026-01-29 19:28:27 -05:00
henrygd c7f7f51c99 add experimental sysfs amd gpu collector (#737, #1569) 2026-01-29 18:35:57 -05:00
henrygd 6723ec8ea4 update honeypot field name and autofill ignores (#1011) 2026-01-28 18:16:30 -05:00
henrygd afc19ebd3b write health_file to /dev/shm instead of /tmp if available (#1455) 2026-01-28 15:21:45 -05:00
Sven van GinkelandGitHub c83d00ccaa Don't force lowercase text for active alerts (#1682) 2026-01-28 13:50:16 -05:00
425c8d2bdf feat: Added tooltips for navbar buttons to clear meaning of each one (#1636)
* feat: Added tooltips for navbar buttons to clear meaning of each one.

* update tooltips and fix linter errors

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-28 13:39:15 -05:00
42da1e5a52 Bug: Apply SELinux context after binary replacement (#1678)
- Move SELinux context handling to internal/ghupdate for reuse
- Make chcon a true fallback (only runs if semanage/restorecon unavailable)
- Handle existing semanage rules with -m (modify) after -a (add) fails
- Apply SELinux handling to both agent and hub updates
- Add tests with proper skip behavior for SELinux systems

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-27 17:39:17 -05:00
Sven van GinkelandGitHub afcae025ae Add icon button for mobile use (#1687) 2026-01-26 20:18:17 -05:00
1de36625a4 [Agent] feat: parse ATA device statistics for temperature and future metrics (#1689)
* feat: add ATA Device Statistics parsing and fall back for SMART temp reading

* simplify ata device statistics structs and fix smartctl args tests

* simplify ata device statistics lookup to use page number only

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-01-26 19:05:55 -05:00
Sven van GinkelandGitHub a2b6c7f5e6 update goreleaser (#1677) 2026-01-25 17:15:28 -05:00
henrygd 799c7b077a support upgrades in agent install script (#1670) 2026-01-23 11:50:40 -05:00
henrygd cb5f944de6 battery: ensure current charge doesn't exceed full capacity (#1668) 2026-01-22 13:01:21 -05:00
henrygd 23c4958145 increase smartctl --scan timeout to 10 seconds (#1465) 2026-01-21 19:09:57 -05:00
henrygd edb2edc12c use name-only matching for unique SMART devices (#1655)
Fall back to name-only matching (previous behavior) when a device name
appears only once, preserving RAID composite key support added in #1655.
2026-01-21 18:25:03 -05:00
Julian NadeauandGitHub 648a979a81 Add SMART_DEVICES_SEPARATOR + allow drives with the same name to be added with different types (e.g. raid controllers) (#1655)
* Add SMART_DEVICES_SEPARATOR to override ,

* Allow composite keys in smart devices for raid controller support
2026-01-21 17:58:20 -05:00
Sven van GinkelandGitHub 988de6de7b chore: update workflows and templates (#1661)
* Update templates

* Add CodeOwners

* Apply Hanks Feedback

* Add note to make one issue per request

* update workflow
2026-01-21 15:36:23 -05:00
henrygd 031abbfcb3 ui: conditional title attribute and better CJK truncation
- Adds CJK support for system name truncation
- Change tooltip to title attribute and show only if system name is truncated
2026-01-16 18:17:45 -05:00
Fahleen ArifandGitHub b59fcc26e5 feat: add tooltip to system name in systems table for better accessibility (#1640) 2026-01-16 17:43:29 -05:00
Tamás VinceandGitHub acaa9381fe fix: update smartctlArgs call to use hasExistingData flag (#1645) 2026-01-16 15:30:52 -05:00
Loïc TosserandGitHub 8d9e9260e6 Change usermod to addgroup for docker access (#1641)
On Alpine Linux, the correct command to add a user to an existing group is addgroup <username> <groupname> rather than usermod -aG. The usermod command is part of the shadow package which is not installed by default on Alpine.
2026-01-14 16:45:23 -05:00
henrygd 0fc4a6daed update install-agent.sh to prefer glibc binary on linux glibc systems 2026-01-12 19:13:14 -05:00
63 changed files with 2797 additions and 610 deletions
+2
View File
@@ -0,0 +1,2 @@
# Everything needs to be reviewed by Hank
* @henrygd
+19
View File
@@ -0,0 +1,19 @@
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
+54 -14
View File
@@ -1,19 +1,54 @@
body:
- type: markdown
- type: checkboxes
id: terms
attributes:
value: |
### Before opening a discussion:
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:
- 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).
Please note:
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
**- Please do not submit support reqeusts that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and discussions and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown
id: component
attributes:
label: Component
description: Which part of Beszel is this about?
options:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: A clear and concise description of the issue or question. If applicable, add screenshots to help explain your problem.
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)
validations:
required: true
- type: input
id: system
attributes:
@@ -21,13 +56,15 @@ 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:
@@ -41,18 +78,21 @@ 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:
+51 -52
View File
@@ -1,8 +1,30 @@
name: 🐛 Bug report
description: Report a new bug or issue.
description: Use this template to report a bug or issue.
title: '[Bug]: '
labels: ['bug', "needs confirmation"]
labels: ['bug']
body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions/new?category=support)** instead
Please note:
- For translation-related issues or requests, please use the [Crowdin project](https://crowdin.com/project/beszel).
- To request a change or feature, use the [feature request form](https://github.com/henrygd/beszel/issues/new?template=feature_request.yml).
- Any issues that can be resolved by consulting the documentation or by reviewing existing open or closed issues will be closed.
**- Please do not submit bugs that are specific to ZFS. We plan to add integration with ZFS utilities in the near future.**
options:
- label: I have read the [Documentation](https://beszel.dev/guide/getting-started)
required: true
- label: I have checked the [Common Issues Guide](https://beszel.dev/guide/common-issues) and my problem was not mentioned there.
required: true
- label: I have searched open and closed issues and my problem was not mentioned before.
required: true
- label: I have verified I am using the latest version available. You can check the latest release [here](https://github.com/henrygd/beszel/releases).
required: true
- type: dropdown
id: component
attributes:
@@ -12,81 +34,53 @@ 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: Description
description: Explain the issue you experienced clearly and concisely.
placeholder: I went to the coffee pot and it was empty.
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)
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: In a perfect world, what should have happened?
description: |
In a perfect world, what should have happened?
**Important:** Be specific. Vague descriptions like "it should work" are not helpful.
placeholder: When I got to the coffee pot, it should have been full.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Describe how to reproduce the issue in repeatable steps.
description: |
Provide detailed, numbered steps that someone else can follow to reproduce the issue.
**Important:** Vague descriptions like "it doesn't work" or "it's broken" will result in the issue being closed.
Include specific actions, URLs, button clicks, and any relevant data or configuration.
placeholder: |
1. Go to the coffee pot.
2. Make more coffee.
3. Pour it into a cup.
4. Observe that the cup is empty instead of full.
validations:
required: true
- type: 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:
@@ -94,6 +88,7 @@ body:
placeholder: linux/amd64 (agent), freebsd/arm64 (hub)
validations:
required: true
- type: input
id: version
attributes:
@@ -101,6 +96,7 @@ body:
placeholder: 0.9.1
validations:
required: true
- type: dropdown
id: install-method
attributes:
@@ -114,18 +110,21 @@ 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:
+3
View File
@@ -1,5 +1,8 @@
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.
+31 -50
View File
@@ -1,8 +1,25 @@
name: 🚀 Feature request
description: Request a new feature or change.
title: "[Feature]: "
labels: ["enhancement", "needs review"]
labels: ["enhancement"]
body:
- type: checkboxes
attributes:
label: Welcome!
description: |
The issue tracker is for reporting bugs and feature requests only. For end-user related support questions, please use the **[GitHub Discussions](https://github.com/henrygd/beszel/discussions)** instead
Please note:
- For **Bug reports**, use the [Bug Form](https://github.com/henrygd/beszel/issues/new?template=bug_report.yml).
- Any requests for new translations should be requested within the [crowdin project](https://crowdin.com/project/beszel).
- Create one issue per feature request. This helps us keep track of requests and prioritize them accordingly.
options:
- label: I have searched open and closed feature requests to make sure this or similar feature request does not already exist.
required: true
- label: This is a feature request, not a bug report or support question.
required: true
- type: dropdown
id: component
attributes:
@@ -12,65 +29,29 @@ body:
- Hub
- Agent
- Hub & Agent
default: 0
validations:
required: true
- type: markdown
attributes:
value: Before submitting, please search existing [issues](https://github.com/henrygd/beszel/issues) and [discussions](https://github.com/henrygd/beszel/discussions) (including closed).
- type: textarea
id: description
attributes:
label: Describe the feature you would like to see
label: Description
description: |
Describe the solution or feature you'd like. Explain what problem this solves or what value it adds.
**Important:** Be specific and detailed. Vague requests like "make it better" will be closed.
placeholder: |
Example:
- What is the feature?
- What problem does it solve?
- How should it work?
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation / Use Case
description: Why do you want this feature? What problem does it solve?
validations:
required: true
- 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
+2 -1
View File
@@ -51,7 +51,8 @@ jobs:
# Labels
stale-issue-label: 'stale'
remove-stale-when-updated: true
only-issue-labels: 'awaiting-requester'
any-of-labels: 'awaiting-requester'
exempt-issue-labels: 'enhancement'
# Exemptions
exempt-assignees: true
-82
View File
@@ -1,82 +0,0 @@
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
});
}
+1 -3
View File
@@ -158,9 +158,7 @@ nfpms:
- debconf
scripts:
templates: ./supplemental/debian/templates
# Currently broken due to a bug in goreleaser
# https://github.com/goreleaser/goreleaser/issues/5487
#config: ./supplemental/debian/config.sh
config: ./supplemental/debian/config.sh
scoops:
- ids: [beszel-agent]
+1 -1
View File
@@ -65,7 +65,7 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
continue
}
totalCapacity += bat.Full
totalCharge += bat.Current
totalCharge += min(bat.Current, bat.Full)
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}
+4
View File
@@ -335,6 +335,8 @@ 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
@@ -403,6 +405,8 @@ 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
+11 -6
View File
@@ -184,11 +184,12 @@ func TestUpdateContainerStatsValues(t *testing.T) {
// Check memory (should be converted to MB: 1048576 bytes = 1 MB)
assert.Equal(t, 1.0, stats.Mem)
// Check network sent (should be converted to MB: 524288 bytes = 0.5 MB)
assert.Equal(t, 0.5, stats.NetworkSent)
// Check bandwidth (raw bytes)
assert.Equal(t, [2]uint64{524288, 262144}, stats.Bandwidth)
// Check network recv (should be converted to MB: 262144 bytes = 0.25 MB)
assert.Equal(t, 0.25, stats.NetworkRecv)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, 0.5, stats.NetworkSent) // 524288 bytes = 0.5 MB
assert.Equal(t, 0.25, stats.NetworkRecv) // 262144 bytes = 0.25 MB
// Check read time
assert.Equal(t, testTime, stats.PrevReadTime)
@@ -527,8 +528,10 @@ func TestContainerStatsInitialization(t *testing.T) {
assert.Equal(t, 45.67, stats.Cpu)
assert.Equal(t, 2.0, stats.Mem)
assert.Equal(t, 1.0, stats.NetworkSent)
assert.Equal(t, 0.5, stats.NetworkRecv)
assert.Equal(t, [2]uint64{1048576, 524288}, stats.Bandwidth)
// Deprecated fields still populated for backward compatibility with older hubs
assert.Equal(t, 1.0, stats.NetworkSent) // 1048576 bytes = 1 MB
assert.Equal(t, 0.5, stats.NetworkRecv) // 524288 bytes = 0.5 MB
assert.Equal(t, testTime, stats.PrevReadTime)
}
@@ -689,6 +692,8 @@ 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)
+21 -4
View File
@@ -21,6 +21,7 @@ const (
// Commands
nvidiaSmiCmd string = "nvidia-smi"
rocmSmiCmd string = "rocm-smi"
amdgpuCmd string = "amdgpu" // internal cmd for sysfs collection
tegraStatsCmd string = "tegrastats"
// Polling intervals
@@ -41,6 +42,7 @@ type GPUManager struct {
sync.Mutex
nvidiaSmi bool
rocmSmi bool
amdgpu bool
tegrastats bool
intelGpuStats bool
nvml bool
@@ -399,7 +401,13 @@ func (gm *GPUManager) detectGPUs() error {
gm.nvidiaSmi = true
}
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
gm.rocmSmi = true
if val, _ := GetEnv("AMD_SYSFS"); val == "true" {
gm.amdgpu = true
} else {
gm.rocmSmi = true
}
} else if gm.hasAmdSysfs() {
gm.amdgpu = true
}
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true
@@ -408,10 +416,10 @@ func (gm *GPUManager) detectGPUs() error {
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
gm.intelGpuStats = true
}
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
if gm.nvidiaSmi || gm.rocmSmi || gm.amdgpu || gm.tegrastats || gm.intelGpuStats || gm.nvml {
return nil
}
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, or intel_gpu_top")
}
// startCollector starts the appropriate GPU data collector based on the command
@@ -448,6 +456,12 @@ 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
@@ -459,7 +473,7 @@ func (gm *GPUManager) startCollector(command string) {
if failures > maxFailureRetries {
break
}
slog.Warn("Error collecting AMD GPU data", "err", err)
slog.Warn("Error collecting AMD GPU data via rocm-smi", "err", err)
}
time.Sleep(rocmSmiInterval)
}
@@ -497,6 +511,9 @@ func NewGPUManager() (*GPUManager, error) {
if gm.rocmSmi {
gm.startCollector(rocmSmiCmd)
}
if gm.amdgpu {
gm.startCollector(amdgpuCmd)
}
if gm.tegrastats {
gm.startCollector(tegraStatsCmd)
}
+184
View File
@@ -0,0 +1,184 @@
//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",
}
})
+15
View File
@@ -0,0 +1,15 @@
//go:build !linux
package agent
import (
"errors"
)
func (gm *GPUManager) hasAmdSysfs() bool {
return false
}
func (gm *GPUManager) collectAmdStats() error {
return errors.ErrUnsupported
}
+22 -6
View File
@@ -9,11 +9,31 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"time"
)
// healthFile is the path to the health file
var healthFile = filepath.Join(os.TempDir(), "beszel_health")
var healthFile = getHealthFilePath()
func getHealthFilePath() string {
filename := "beszel_health"
if runtime.GOOS == "linux" {
fullPath := filepath.Join("/dev/shm", filename)
if err := updateHealthFile(fullPath); err == nil {
return fullPath
}
}
return filepath.Join(os.TempDir(), filename)
}
func updateHealthFile(path string) error {
file, err := os.Create(path)
if err != nil {
return err
}
return file.Close()
}
// Check checks if the agent is connected by checking the modification time of the health file
func Check() error {
@@ -30,11 +50,7 @@ func Check() error {
// Update updates the modification time of the health file
func Update() error {
file, err := os.Create(healthFile)
if err != nil {
return err
}
return file.Close()
return updateHealthFile(healthFile)
}
// CleanUp removes the health file
+6 -1
View File
@@ -52,7 +52,12 @@ class Program
foreach (var sensor in hardware.Sensors)
{
var validTemp = sensor.SensorType == SensorType.Temperature && sensor.Value.HasValue;
if (!validTemp || sensor.Name.Contains("Distance"))
if (!validTemp ||
sensor.Name.IndexOf("Distance", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Critical", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Warning", StringComparison.OrdinalIgnoreCase) >= 0 ||
sensor.Name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0)
{
continue;
}
+3 -1
View File
@@ -3,9 +3,11 @@
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<Platforms>x64</Platforms>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.4" />
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
</ItemGroup>
</Project>
+146 -36
View File
@@ -54,6 +54,12 @@ 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
@@ -165,7 +171,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
configuredDevices = parsedDevices
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
@@ -202,7 +208,11 @@ func (sm *SmartManager) ScanDevices(force bool) error {
}
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
entries := strings.Split(config, ",")
splitChar := os.Getenv("SMART_DEVICES_SEPARATOR")
if splitChar == "" {
splitChar = ","
}
entries := strings.Split(config, splitChar)
devices := make([]*DeviceInfo, 0, len(entries))
for _, entry := range entries {
entry = strings.TrimSpace(entry)
@@ -326,6 +336,13 @@ 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 {
@@ -435,7 +452,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
defer cancel()
// Try with -n standby first if we have existing data
args := sm.smartctlArgs(deviceInfo, true)
args := sm.smartctlArgs(deviceInfo, hasExistingData)
cmd := exec.CommandContext(ctx, sm.binPath, args...)
output, err := cmd.CombinedOutput()
@@ -498,10 +515,12 @@ 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, 7)
args := make([]string, 0, 9)
var deviceType, parserType string
if deviceInfo != nil {
deviceType := strings.ToLower(deviceInfo.Type)
deviceType = strings.ToLower(deviceInfo.Type)
parserType = strings.ToLower(deviceInfo.parserType)
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
args = append(args, "-d", deviceInfo.Type)
@@ -509,6 +528,13 @@ 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")
@@ -569,6 +595,28 @@ 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.
@@ -581,69 +629,90 @@ func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo
target.parserType = prev.parserType
}
existingIndex := make(map[string]*DeviceInfo, len(existing))
// applyConfiguredMetadata updates a matched device with any configured
// overrides, preserving verified type data when present.
applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) {
// Only update the type if it has not been verified yet; otherwise we
// keep the existing verified metadata intact.
if configuredDev.Type != "" && !existingDev.typeVerified {
newType := strings.TrimSpace(configuredDev.Type)
existingDev.Type = newType
existingDev.typeVerified = false
existingDev.parserType = normalizeParserType(newType)
}
if configuredDev.InfoName != "" {
existingDev.InfoName = configuredDev.InfoName
}
if configuredDev.Protocol != "" {
existingDev.Protocol = configuredDev.Protocol
}
}
existingIndex := make(map[deviceKey]*DeviceInfo, len(existing))
for _, dev := range existing {
if dev == nil || dev.Name == "" {
continue
}
existingIndex[dev.Name] = dev
existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev
}
existingByName := buildUniqueNameIndex(existing)
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured))
// Start with the newly scanned devices so we always surface fresh metadata,
// but ensure we retain any previously verified parser assignment.
for _, dev := range scanned {
if dev == nil || dev.Name == "" {
for _, scannedDevice := range scanned {
if scannedDevice == nil || scannedDevice.Name == "" {
continue
}
// Work on a copy so we can safely adjust metadata without mutating the
// input slices that may be reused elsewhere.
copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
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 {
preserveVerifiedType(&copyDev, prev)
}
finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
}
deviceIndexByName := buildUniqueNameIndex(finalDevices)
// Merge configured devices on top so users can override scan results (except
// for verified type information).
for _, dev := range configured {
if dev == nil || dev.Name == "" {
for _, configuredDevice := range configured {
if configuredDevice == nil || configuredDevice.Name == "" {
continue
}
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
}
key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type)
if existingDev, ok := deviceIndex[key]; ok {
applyConfiguredMetadata(existingDev, configuredDevice)
continue
}
if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
applyConfiguredMetadata(existingDev, configuredDevice)
continue
}
copyDev := *dev
if prev := existingIndex[copyDev.Name]; prev != nil {
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 {
preserveVerifiedType(&copyDev, prev)
} else if copyDev.Type != "" {
copyDev.parserType = normalizeParserType(copyDev.Type)
}
finalDevices = append(finalDevices, &copyDev)
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
}
return finalDevices
@@ -661,12 +730,14 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
return
}
validNames := make(map[string]struct{}, len(devices))
validKeys := make(map[deviceKey]struct{}, len(devices))
nameCounts := make(map[string]int, len(devices))
for _, device := range devices {
if device == nil || device.Name == "" {
continue
}
validNames[device.Name] = struct{}{}
validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{}
nameCounts[device.Name]++
}
for key, data := range sm.SmartDataMap {
@@ -675,7 +746,11 @@ func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
continue
}
if _, ok := validNames[data.DiskName]; ok {
if data.DiskType == "" {
if nameCounts[data.DiskName] == 1 {
continue
}
} else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok {
continue
}
@@ -763,6 +838,11 @@ 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
@@ -801,6 +881,36 @@ 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
+141 -2
View File
@@ -89,6 +89,39 @@ 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},
@@ -195,6 +228,24 @@ 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")
@@ -249,15 +300,21 @@ func TestSmartctlArgs(t *testing.T) {
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
sm.smartctlArgs(sataDevice, true),
)
assert.Equal(t,
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
sm.smartctlArgs(sataDevice, false),
)
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
assert.Equal(t,
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
sm.smartctlArgs(nvmeDevice, true),
)
assert.Equal(t,
[]string{"-a", "--json=c", "-n", "standby"},
sm.smartctlArgs(nil, true),
@@ -442,6 +499,88 @@ 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)
+14
View File
@@ -144,13 +144,27 @@ 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
}
+3 -43
View File
@@ -1,12 +1,10 @@
package agent
import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"github.com/henrygd/beszel/internal/ghupdate"
)
@@ -108,12 +106,12 @@ func Update(useMirror bool) error {
}
}
// 6) Fix SELinux context if necessary
if err := handleSELinuxContext(exePath); err != nil {
// Fix SELinux context if necessary
if err := ghupdate.HandleSELinuxContext(exePath); err != nil {
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
}
// 7) Restart service if running under a recognised init system
// 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)
@@ -128,41 +126,3 @@ 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
}
+2
View File
@@ -105,6 +105,8 @@ func NewAlertManager(app hubLike) *AlertManager {
func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("container_alerts").BindFunc(updateHistoryOnContainerAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("container_alerts").BindFunc(resolveHistoryOnContainerAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
}
+116
View File
@@ -117,3 +117,119 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}
// UpsertUserContainerAlerts handles API request to create or update container alerts for a user
// across multiple containers (POST /api/beszel/user-container-alerts)
func UpsertUserContainerAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
Min uint8 `json:"min"`
Value float64 `json:"value"`
Name string `json:"name"`
Systems []string `json:"systems"`
Containers []string `json:"containers"`
Overwrite bool `json:"overwrite"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 || len(reqData.Containers) == 0 {
return e.BadRequestError("Bad data", err)
}
containerAlertsCollection, err := e.App.FindCachedCollectionByNameOrId("container_alerts")
if err != nil {
return err
}
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
for _, containerId := range reqData.Containers {
// find existing matching alert
alertRecord, err := txApp.FindFirstRecordByFilter(containerAlertsCollection,
"system={:system} && container={:container} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "container": containerId, "name": reqData.Name, "user": userID})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// skip if alert already exists and overwrite is not set
if !reqData.Overwrite && alertRecord != nil {
continue
}
// create new alert if it doesn't exist
if alertRecord == nil {
alertRecord = core.NewRecord(containerAlertsCollection)
alertRecord.Set("user", userID)
alertRecord.Set("system", systemId)
alertRecord.Set("container", containerId)
alertRecord.Set("name", reqData.Name)
}
alertRecord.Set("value", reqData.Value)
alertRecord.Set("min", reqData.Min)
if err := txApp.SaveNoValidate(alertRecord); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true})
}
// DeleteUserContainerAlerts handles API request to delete container alerts for a user
// across multiple containers (DELETE /api/beszel/user-container-alerts)
func DeleteUserContainerAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
AlertName string `json:"name"`
Systems []string `json:"systems"`
Containers []string `json:"containers"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 || len(reqData.Containers) == 0 {
return e.BadRequestError("Bad data", err)
}
var numDeleted uint16
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
for _, containerId := range reqData.Containers {
// Find existing alert to delete
alertRecord, err := txApp.FindFirstRecordByFilter("container_alerts",
"system={:system} && container={:container} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "container": containerId, "name": reqData.AlertName, "user": userID})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// alert doesn't exist, continue to next container
continue
}
return err
}
if err := txApp.Delete(alertRecord); err != nil {
return err
}
numDeleted++
}
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}
+394
View File
@@ -0,0 +1,394 @@
package alerts
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// ContainerAlertData holds the data for a container alert
type ContainerAlertData struct {
systemRecord *core.Record
containerRecord *core.Record
alertRecord *core.Record
name string
unit string
val float64
threshold float64
triggered bool
time time.Time
count uint8
min uint8
descriptor string // override descriptor in notification body
}
// ContainerAlertStats represents the stats structure for container alerts
type ContainerAlertStats struct {
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
Status string `json:"s"`
Health string `json:"h"`
}
// HandleContainerAlerts processes container alerts for a system
func (am *AlertManager) HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData) error {
containers := data.Containers
if len(containers) == 0 {
return nil
}
// Get all container alerts for this system
alertRecords, err := am.hub.FindAllRecords("container_alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
return nil
}
// Group alerts by container
containerAlerts := make(map[string][]*core.Record)
for _, alertRecord := range alertRecords {
containerId := alertRecord.GetString("container")
containerAlerts[containerId] = append(containerAlerts[containerId], alertRecord)
}
// Process alerts for each container
for containerId, alerts := range containerAlerts {
// Find the container in the current stats
var containerStats *container.Stats
for _, c := range containers {
if c.Id == containerId {
containerStats = c
break
}
}
// If container not found in current stats, it might be stopped
// We'll still process Status and Health alerts
if containerStats == nil {
for _, alertRecord := range alerts {
name := alertRecord.GetString("name")
if name == "Status" || name == "Health" {
// Container is missing, so it's likely stopped
go am.sendContainerStatusAlert(alertRecord, systemRecord, containerId, "stopped")
}
}
continue
}
// Process each alert for this container
var validAlerts []ContainerAlertData
now := time.Now().UTC()
oldestTime := now
for _, alertRecord := range alerts {
name := alertRecord.GetString("name")
var val float64
unit := "%"
switch name {
case "CPU":
val = containerStats.Cpu
case "Memory":
val = containerStats.Mem
case "Network":
val = containerStats.NetworkSent + containerStats.NetworkRecv
unit = " MB/s"
case "Status":
// Status alert is handled differently - immediate trigger
triggered := alertRecord.GetBool("triggered")
isRunning := containerStats.Status == "running"
if (!triggered && !isRunning) || (triggered && isRunning) {
go am.sendContainerStatusAlert(alertRecord, systemRecord, containerId, containerStats.Status)
}
continue
case "Health":
// Health alert - trigger on unhealthy status
triggered := alertRecord.GetBool("triggered")
isHealthy := containerStats.Health == container.DockerHealthNone || containerStats.Health == container.DockerHealthHealthy
if (!triggered && !isHealthy) || (triggered && isHealthy) {
go am.sendContainerHealthAlert(alertRecord, systemRecord, containerId, containerStats.Health)
}
continue
default:
continue
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
min := max(1, cast.ToUint8(alertRecord.Get("min")))
alert := ContainerAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
unit: unit,
val: val,
threshold: threshold,
triggered: triggered,
min: min,
}
// Send alert immediately if min is 1
if min == 1 {
alert.triggered = val > threshold
go am.sendContainerAlert(alert, containerId, containerStats.Name)
continue
}
alert.time = now.Add(-time.Duration(min) * time.Minute)
if alert.time.Before(oldestTime) {
oldestTime = alert.time
}
validAlerts = append(validAlerts, alert)
}
if len(validAlerts) == 0 {
continue
}
// Fetch historical container stats
containerStatsRecords := []struct {
Stats []byte `db:"stats"`
Created types.DateTime `db:"created"`
}{}
err = am.hub.DB().
Select("stats", "created").
From("container_stats").
Where(dbx.NewExp(
"system={:system} AND type='1m' AND created > {:created}",
dbx.Params{
"system": systemRecord.Id,
"created": oldestTime.Add(-time.Second * 90),
},
)).
OrderBy("created").
All(&containerStatsRecords)
if err != nil || len(containerStatsRecords) == 0 {
continue
}
oldestRecordTime := containerStatsRecords[0].Created.Time()
// Filter valid alerts
filteredAlerts := make([]ContainerAlertData, 0, len(validAlerts))
for _, alert := range validAlerts {
if alert.time.After(oldestRecordTime) {
filteredAlerts = append(filteredAlerts, alert)
}
}
validAlerts = filteredAlerts
if len(validAlerts) == 0 {
continue
}
// Process historical stats
for i := range containerStatsRecords {
stat := containerStatsRecords[i]
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
// Parse container stats array
var allContainerStats []*container.Stats
if err := json.Unmarshal(stat.Stats, &allContainerStats); err != nil {
continue
}
// Find this container in the stats
var thisContainerStats *container.Stats
for _, cs := range allContainerStats {
if cs.Id == containerId {
thisContainerStats = cs
break
}
}
if thisContainerStats == nil {
continue
}
for j := range validAlerts {
alert := &validAlerts[j]
if i == 0 {
alert.val = 0
}
if systemStatsCreation.Before(alert.time) {
continue
}
switch alert.name {
case "CPU":
alert.val += thisContainerStats.Cpu
case "Memory":
alert.val += thisContainerStats.Mem
case "Network":
alert.val += thisContainerStats.NetworkSent + thisContainerStats.NetworkRecv
default:
continue
}
alert.count++
}
}
// Calculate averages and send alerts
for _, alert := range validAlerts {
alert.val = alert.val / float64(alert.count)
minCount := float32(alert.min) / 1.2
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendContainerAlert(alert, containerId, containerStats.Name)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendContainerAlert(alert, containerId, containerStats.Name)
}
}
}
}
return nil
}
// sendContainerAlert sends a container alert notification
func (am *AlertManager) sendContainerAlert(alert ContainerAlertData, containerId, containerName string) {
systemName := alert.systemRecord.GetString("name")
var subject string
if alert.triggered {
subject = fmt.Sprintf("%s container %s %s above threshold", systemName, containerName, strings.ToLower(alert.name))
} else {
subject = fmt.Sprintf("%s container %s %s below threshold", systemName, containerName, strings.ToLower(alert.name))
}
minutesLabel := "minute"
if alert.min > 1 {
minutesLabel += "s"
}
descriptor := alert.name
if alert.descriptor != "" {
descriptor = alert.descriptor
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.hub.Save(alert.alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"),
SystemID: alert.systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", alert.systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}
// sendContainerStatusAlert sends a container status change alert
func (am *AlertManager) sendContainerStatusAlert(alertRecord, systemRecord *core.Record, containerId, status string) {
triggered := alertRecord.GetBool("triggered")
isRunning := status == "running"
// Only send if state changed
if (!triggered && isRunning) || (triggered && !isRunning) {
return
}
systemName := systemRecord.GetString("name")
// Try to get container name from containers collection
containerName := containerId
if containerRecord, err := am.hub.FindFirstRecordByFilter("containers", "id={:id}", dbx.Params{"id": containerId}); err == nil {
containerName = containerRecord.GetString("name")
}
var subject string
if isRunning {
subject = fmt.Sprintf("%s container %s is now running", systemName, containerName)
} else {
subject = fmt.Sprintf("%s container %s has stopped", systemName, containerName)
}
body := fmt.Sprintf("Container status changed to: %s", status)
alertRecord.Set("triggered", !isRunning)
if err := am.hub.Save(alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
SystemID: systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}
// sendContainerHealthAlert sends a container health status change alert
func (am *AlertManager) sendContainerHealthAlert(alertRecord, systemRecord *core.Record, containerId string, health container.DockerHealth) {
triggered := alertRecord.GetBool("triggered")
isHealthy := health == container.DockerHealthNone || health == container.DockerHealthHealthy
// Only send if state changed
if (!triggered && isHealthy) || (triggered && !isHealthy) {
return
}
systemName := systemRecord.GetString("name")
// Try to get container name from containers collection
containerName := containerId
if containerRecord, err := am.hub.FindFirstRecordByFilter("containers", "id={:id}", dbx.Params{"id": containerId}); err == nil {
containerName = containerRecord.GetString("name")
}
var subject string
if isHealthy {
subject = fmt.Sprintf("%s container %s is now healthy", systemName, containerName)
} else {
subject = fmt.Sprintf("%s container %s is unhealthy", systemName, containerName)
}
// Convert DockerHealth to string
healthStatus := "unknown"
for str, val := range container.DockerHealthStrings {
if val == health {
healthStatus = str
break
}
}
body := fmt.Sprintf("Container health status changed to: %s", healthStatus)
alertRecord.Set("triggered", !isHealthy)
if err := am.hub.Save(alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
SystemID: systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}
+67
View File
@@ -72,3 +72,70 @@ func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHist
}
return alertHistoryRecord, err
}
// On triggered container alert record delete, set matching alert history record to resolved
func resolveHistoryOnContainerAlertDelete(e *core.RecordEvent) error {
if !e.Record.GetBool("triggered") {
return e.Next()
}
_ = resolveContainerAlertHistoryRecord(e.App, e.Record.Id)
return e.Next()
}
// On container alert record update, update alert history record
func updateHistoryOnContainerAlertUpdate(e *core.RecordEvent) error {
original := e.Record.Original()
new := e.Record
originalTriggered := original.GetBool("triggered")
newTriggered := new.GetBool("triggered")
// no need to update alert history if triggered state has not changed
if originalTriggered == newTriggered {
return e.Next()
}
// if new state is triggered, create new alert history record
if newTriggered {
_, _ = createContainerAlertHistoryRecord(e.App, new)
return e.Next()
}
// if new state is not triggered, check for matching alert history record and set it to resolved
_ = resolveContainerAlertHistoryRecord(e.App, new.Id)
return e.Next()
}
// resolveContainerAlertHistoryRecord sets the resolved field to the current time
func resolveContainerAlertHistoryRecord(app core.App, alertRecordID string) error {
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
if err != nil || alertHistoryRecord == nil {
return err
}
alertHistoryRecord.Set("resolved", time.Now().UTC())
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to resolve container alert history", "err", err)
}
return err
}
// createContainerAlertHistoryRecord creates a new container alert history record
func createContainerAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
if err != nil {
return nil, err
}
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
alertHistoryRecord.Set("alert_id", alertRecord.Id)
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
alertHistoryRecord.Set("container", alertRecord.GetString("container"))
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to save container alert history", "err", err)
}
return alertHistoryRecord, err
}
+6 -5
View File
@@ -129,11 +129,12 @@ 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" cbor:"3,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
Name string `json:"n" cbor:"0,keyasint"`
Cpu float64 `json:"c" cbor:"1,keyasint"`
Mem float64 `json:"m" cbor:"2,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
Health DockerHealth `json:"-" cbor:"5,keyasint"`
Status string `json:"-" cbor:"6,keyasint"`
+16 -2
View File
@@ -130,10 +130,23 @@ 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"`
@@ -343,7 +356,8 @@ 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"`
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
AtaDeviceStatistics AtaDeviceStatistics `json:"ata_device_statistics"`
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
// PowerCycleCount uint16 `json:"power_cycle_count"`
Temperature TemperatureInfo `json:"temperature"`
+2 -2
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" cbor:"16,keyasint"`
NetworkRecv float64 `json:"nr" cbor:"17,keyasint"`
NetworkSent float64 `json:"ns,omitzero" cbor:"16,keyasint,omitzero"`
NetworkRecv float64 `json:"nr,omitzero" cbor:"17,keyasint,omitzero"`
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"`
+66
View File
@@ -0,0 +1,66 @@
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
}
+53
View File
@@ -0,0 +1,53 @@
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")
}
}
+31 -1
View File
@@ -194,7 +194,34 @@ func setCollectionAuthSettings(app core.App) error {
}
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
containersCollection.ListRule = &containersListRule
return app.Save(containersCollection)
if err := app.Save(containersCollection); err != nil {
return err
}
// allow all users to access system-related collections if SHARE_ALL_SYSTEMS is set
// these collections all have a "system" relation field
systemRelatedCollections := []string{"system_details", "smart_devices", "systemd_services"}
for _, collectionName := range systemRelatedCollections {
collection, err := app.FindCollectionByNameOrId(collectionName)
if err != nil {
return err
}
collection.ListRule = &containersListRule
// set viewRule for collections that need it (system_details, smart_devices)
if collection.ViewRule != nil {
collection.ViewRule = &containersListRule
}
// set deleteRule for smart_devices (allows user to dismiss disk warnings)
if collectionName == "smart_devices" {
deleteRule := containersListRule + " && @request.auth.role != \"readonly\""
collection.DeleteRule = &deleteRule
}
if err := app.Save(collection); err != nil {
return err
}
}
return nil
}
// registerCronJobs sets up scheduled tasks
@@ -269,6 +296,9 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// update / delete container alerts
apiAuth.POST("/user-container-alerts", alerts.UpsertUserContainerAlerts)
apiAuth.DELETE("/user-container-alerts", alerts.DeleteUserContainerAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
+5 -1
View File
@@ -317,7 +317,11 @@ 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
params["net"+suffix] = container.NetworkSent + container.NetworkRecv
netBytes := container.Bandwidth[0] + container.Bandwidth[1]
if netBytes == 0 {
netBytes = uint64((container.NetworkSent + container.NetworkRecv) * 1024 * 1024)
}
params["net"+suffix] = netBytes
}
queryString := fmt.Sprintf(
"INSERT INTO containers (id, system, name, image, 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",
+5
View File
@@ -51,6 +51,7 @@ type hubLike interface {
core.App
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
@@ -211,6 +212,10 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
// Trigger container alerts
if err := sm.hub.HandleContainerAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling container alerts", "err", err)
}
}
// Trigger status change alerts for up/down transitions
+5
View File
@@ -45,6 +45,11 @@ 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()
}
@@ -0,0 +1,208 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// 1. Create container_alerts collection
// Check if collection already exists to prevent errors on restart
if _, err := app.FindCollectionByNameOrId("container_alerts"); err == nil {
// Collection exists, skip creation
} else {
jsonData := `{
"id": "ca_container_alerts",
"name": "container_alerts",
"type": "base",
"system": false,
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"fields": [
{
"id": "text_id",
"name": "id",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"system": true,
"primaryKey": true,
"autogeneratePattern": "[a-z0-9]{15}",
"min": 15,
"max": 15,
"pattern": "^[a-z0-9]+$"
},
{
"id": "rel_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"system": false,
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
},
{
"id": "rel_system",
"name": "system",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"system": false,
"collectionId": "2hz5ncl8tizk5nx",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
},
{
"id": "text_container",
"name": "container",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"system": false,
"min": null,
"max": null,
"pattern": ""
},
{
"id": "select_name",
"name": "name",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"system": false,
"maxSelect": 1,
"values": [
"Status",
"CPU",
"Memory",
"Network",
"Health"
]
},
{
"id": "number_value",
"name": "value",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"system": false,
"min": null,
"max": null,
"onlyInt": false
},
{
"id": "number_min",
"name": "min",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"system": false,
"min": null,
"max": 60,
"onlyInt": true
},
{
"id": "bool_triggered",
"name": "triggered",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"system": false
},
{
"id": "autodate_created",
"name": "created",
"type": "autodate",
"required": false,
"presentable": false,
"unique": false,
"system": false,
"onCreate": true,
"onUpdate": false
},
{
"id": "autodate_updated",
"name": "updated",
"type": "autodate",
"required": false,
"presentable": false,
"unique": false,
"system": false,
"onCreate": true,
"onUpdate": true
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_container_alerts_unique` + "`" + ` ON ` + "`" + `container_alerts` + "`" + ` (` + "`" + `user` + "`" + `, ` + "`" + `system` + "`" + `, ` + "`" + `container` + "`" + `, ` + "`" + `name` + "`" + `)"
]
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), collection); err != nil {
return err
}
if err := app.Save(collection); err != nil {
return err
}
}
// 2. Add container field to alerts_history collection
alertsHistoryCollection, err := app.FindCollectionByNameOrId("alerts_history")
if err != nil {
return err
}
if alertsHistoryCollection.Fields.GetByName("container") == nil {
containerField := &core.TextField{
Name: "container",
Required: false,
}
alertsHistoryCollection.Fields.Add(containerField)
return app.Save(alertsHistoryCollection)
}
return nil
}, func(app core.App) error {
// Rollback 2: remove the container field
alertsHistoryCollection, err := app.FindCollectionByNameOrId("alerts_history")
if err == nil {
if field := alertsHistoryCollection.Fields.GetByName("container"); field != nil {
alertsHistoryCollection.Fields.RemoveByName("container")
if err := app.Save(alertsHistoryCollection); err != nil {
return err
}
}
}
// Rollback 1: delete the container_alerts collection
collection, err := app.FindCollectionByNameOrId("container_alerts")
if err == nil {
return app.Delete(collection)
}
return nil
})
}
+12 -7
View File
@@ -461,19 +461,24 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
}
sums[stat.Name].Cpu += stat.Cpu
sums[stat.Name].Mem += stat.Mem
sums[stat.Name].NetworkSent += stat.NetworkSent
sums[stat.Name].NetworkRecv += stat.NetworkRecv
sentBytes := stat.Bandwidth[0]
recvBytes := stat.Bandwidth[1]
if sentBytes == 0 && recvBytes == 0 && (stat.NetworkSent != 0 || stat.NetworkRecv != 0) {
sentBytes = uint64(stat.NetworkSent * 1024 * 1024)
recvBytes = uint64(stat.NetworkRecv * 1024 * 1024)
}
sums[stat.Name].Bandwidth[0] += sentBytes
sums[stat.Name].Bandwidth[1] += recvBytes
}
}
result := make([]container.Stats, 0, len(sums))
for _, value := range sums {
result = append(result, container.Stats{
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
NetworkSent: twoDecimals(value.NetworkSent / count),
NetworkRecv: twoDecimals(value.NetworkRecv / count),
Name: value.Name,
Cpu: twoDecimals(value.Cpu / count),
Mem: twoDecimals(value.Mem / count),
Bandwidth: [2]uint64{uint64(float64(value.Bandwidth[0]) / count), uint64(float64(value.Bandwidth[1]) / count)},
})
}
return result
@@ -56,7 +56,7 @@ export const ActiveAlerts = () => {
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
{systems[alert.system]?.name} {info.name()}
</AlertTitle>
<AlertDescription>
{alert.name === "Status" ? (
+6 -4
View File
@@ -49,10 +49,12 @@ 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 -ms-1" />
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
<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>
</Button>
</DialogTrigger>
{opened.current && <SystemDialog setOpen={setOpen} />}
@@ -0,0 +1,44 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { BellIcon } from "lucide-react"
import { memo, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { $containerAlerts } from "@/lib/stores"
import { cn } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
import { ContainerAlertDialogContent } from "./container-alerts-sheet"
export default memo(function ContainerAlertButton({
systemId,
container,
}: {
systemId: string
container: ContainerRecord
}) {
const [opened, setOpened] = useState(false)
const alerts = useStore($containerAlerts)
const containerAlerts = alerts[systemId]?.get(container.id)
const hasContainerAlert = containerAlerts && containerAlerts.size > 0
return useMemo(
() => (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
"fill-primary": hasContainerAlert,
})}
/>
</Button>
</SheetTrigger>
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
{opened && <ContainerAlertDialogContent systemId={systemId} container={container} />}
</SheetContent>
</Sheet>
),
[opened, hasContainerAlert]
)
})
@@ -0,0 +1,327 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { BoxIcon, GlobeIcon } from "lucide-react"
import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { toast } from "@/components/ui/use-toast"
import { containerAlertInfo } from "@/lib/container-alerts"
import { pb } from "@/lib/api"
import { $containerAlerts } from "@/lib/stores"
import { cn, debounce } from "@/lib/utils"
import type { ContainerAlertInfo, ContainerAlertRecord, ContainerRecord } from "@/types"
const Slider = lazy(() => import("@/components/ui/slider"))
const endpoint = "/api/beszel/user-container-alerts"
const alertDebounce = 100
const alertKeys = Object.keys(containerAlertInfo) as (keyof typeof containerAlertInfo)[]
const failedUpdateToast = (error: unknown) => {
console.error(error)
toast({
title: t`Failed to update alert`,
description: t`Please check logs for more details.`,
variant: "destructive",
})
}
/** Create or update container alerts */
const upsertContainerAlerts = debounce(
async ({
name,
value,
min,
systems,
containers,
}: {
name: string
value: number
min: number
systems: string[]
containers: string[]
}) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "POST",
body: { name, value, min, systems, containers, overwrite: true },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
/** Delete container alerts */
const deleteContainerAlerts = debounce(
async ({ name, systems, containers }: { name: string; systems: string[]; containers: string[] }) => {
try {
await pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems, containers },
})
} catch (error) {
failedUpdateToast(error)
}
},
alertDebounce
)
export const ContainerAlertDialogContent = memo(function ContainerAlertDialogContent({
systemId,
container,
}: {
systemId: string
container: ContainerRecord
}) {
const alerts = useStore($containerAlerts)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("container")
const containerAlerts = alerts[systemId]?.get(container.id) ?? new Map()
// Keep a copy of alerts when we switch to global tab
const alertsWhenGlobalSelected = useMemo(() => {
return currentTab === "global" ? structuredClone(alerts) : alerts
}, [currentTab])
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
<Trans>Container Alerts</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
See{" "}
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
notification settings
</Link>{" "}
to configure how you receive alerts.
</Trans>
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="container" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="container">
<BoxIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{container.name}</span>
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Containers</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="container">
<div className="grid gap-3">
{alertKeys.map((name) => (
<ContainerAlertContent
key={name}
alertKey={name}
data={containerAlertInfo[name as keyof typeof containerAlertInfo]}
alert={containerAlerts.get(name)}
systemId={systemId}
container={container}
/>
))}
</div>
</TabsContent>
<TabsContent value="global">
<label
htmlFor="ovw"
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
>
<Checkbox
id="ovw"
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
checked={overwriteExisting}
onCheckedChange={setOverwriteExisting}
/>
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{alertKeys.map((name) => (
<ContainerAlertContent
key={name}
alertKey={name}
systemId={systemId}
container={container}
alert={containerAlerts.get(name)}
data={containerAlertInfo[name as keyof typeof containerAlertInfo]}
global={true}
overwriteExisting={!!overwriteExisting}
initialAlertsState={alertsWhenGlobalSelected}
/>
))}
</div>
</TabsContent>
</Tabs>
</>
)
})
export function ContainerAlertContent({
alertKey,
data: alertData,
systemId,
container,
alert,
global = false,
overwriteExisting = false,
initialAlertsState = {},
}: {
alertKey: string
data: ContainerAlertInfo
systemId: string
container: ContainerRecord
alert?: ContainerAlertRecord
global?: boolean
overwriteExisting?: boolean
initialAlertsState?: Record<string, Map<string, Map<string, ContainerAlertRecord>>>
}) {
const { name } = alertData
const singleDescription = alertData.singleDesc?.()
const [checked, setChecked] = useState(global ? false : !!alert)
const [min, setMin] = useState(alert?.min || 10)
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
const Icon = alertData.icon
/** Get container ids to update */
function getContainerIds(): string[] {
// if not global, update only the current container
if (!global) {
return [container.id]
}
// if global, we need to get all containers for this system
// For now, we'll just use the current container
// In a real implementation, you'd fetch all containers for the system
return [container.id]
}
function sendUpsert(min: number, value: number) {
const containers = getContainerIds()
containers.length &&
upsertContainerAlerts({
name: alertKey,
value,
min,
systems: [systemId],
containers,
})
}
return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label
htmlFor={`c${name}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": checked,
})}
>
<div className="grid gap-1 select-none">
<p className="font-semibold flex gap-3 items-center">
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
</p>
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
</div>
<Switch
id={`c${name}`}
checked={checked}
onCheckedChange={(newChecked) => {
setChecked(newChecked)
if (newChecked) {
// if alert checked, create or update alert
sendUpsert(min, value)
} else {
// if unchecked, delete alert
deleteContainerAlerts({ name: alertKey, systems: [systemId], containers: getContainerIds() })
// when force deleting all alerts of a type, also remove them from initialAlertsState
if (overwriteExisting) {
for (const systemAlerts of Object.values(initialAlertsState)) {
for (const containerAlerts of systemAlerts.values()) {
containerAlerts.delete(alertKey)
}
}
}
}
}}
/>
</label>
{checked && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}>
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
{alertData.invert ? (
<Trans>
Average drops below{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
) : (
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
)}
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[value]}
onValueCommit={(val) => sendUpsert(min, val[0])}
onValueChange={(val) => setValue(val[0])}
step={alertData.step ?? 1}
min={alertData.min ?? 1}
max={alertData.max ?? 99}
/>
</div>
</div>
)}
<div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && (
<>
{singleDescription}
{` `}
</>
)}
<Trans>
For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one="minute" other="minutes" />
</Trans>
</p>
<div className="flex gap-3">
<Slider
aria-labelledby={`v${name}`}
defaultValue={[min]}
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
onValueChange={(val) => setMin(val[0])}
min={1}
max={60}
/>
</div>
</div>
</Suspense>
</div>
)}
</div>
)
}
@@ -2,7 +2,14 @@
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"
@@ -47,27 +54,49 @@ export default memo(function ContainerChart({
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, true)
const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart)
return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`)
}
}
// tooltip formatter
if (isNetChart) {
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
if (record?.b?.length && record.b.length >= 2) {
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
}
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
}
const formatRxTx = (recv: number, sent: number) => {
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
return (
<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 {
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>
)
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const value of Object.values(payloadData)) {
if (!value || typeof value !== "object") {
continue
}
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
totalSent += sent
totalRecv += recv
}
return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
} catch (e) {
return null
}
@@ -82,7 +111,15 @@ export default memo(function ContainerChart({
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => (data[key] ? data[key].nr + data[key].ns : null)
obj.dataFunction = (key: string, data: any) => {
const payload = data[key]
if (!payload) {
return null
}
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
return sent + recv
}
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
@@ -94,11 +131,16 @@ export default memo(function ContainerChart({
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))
}))
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())
+3 -1
View File
@@ -50,10 +50,12 @@ 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 + (containerStats.nr ?? 0) + (containerStats.ns ?? 0))
totalUsage.network.set(containerName, currentNetwork + sentBytes + recvBytes)
}
}
@@ -1,4 +1,5 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { lazy, Suspense } from "react"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { ContainerRecord } from "@/types"
@@ -20,7 +21,14 @@ 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(" ")
@@ -33,6 +41,9 @@ function getStatusValue(status: string): number {
return 0
}
// Lazy load the alert button component
const ContainerAlertButton = lazy(() => import("@/components/alerts/container-alert-button"))
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
{
id: "name",
@@ -55,7 +66,7 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
cell: ({ getValue }) => {
const allSystems = useStore($allSystemsById)
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
return <span className="ms-1.5 xl:w-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
},
},
// {
@@ -97,7 +108,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, true)
const formatted = formatBytes(val, true, undefined, false)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
@@ -113,13 +124,14 @@ 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>
)
@@ -129,15 +141,17 @@ export const containerChartCols: ColumnDef<ContainerRecord>[] = [
id: "image",
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
accessorFn: (record) => record.image,
enableHiding: true,
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>
return <span className="ms-1.5 xl:w-30 block truncate">{getValue() as string}</span>
},
},
{
id: "status",
accessorFn: (record) => record.status,
invertSorting: true,
enableHiding: true,
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
cell: ({ getValue }) => {
@@ -151,20 +165,42 @@ 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>
},
},
{
id: "actions",
header: () => <div className="text-center">{t`Actions`}</div>,
cell: ({ row }) => {
// Lazy load the alert button component
const ContainerAlertButton = lazy(() => import("@/components/alerts/container-alert-button"))
return (
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
<div className="flex justify-center" onClick={(e) => e.stopPropagation()}>
<Suspense fallback={<div className="h-8 w-8" />}>
<ContainerAlertButton systemId={row.original.system} container={row.original} />
</Suspense>
</div>
)
},
},
]
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")}
>
@@ -173,4 +209,4 @@ function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>;
<ArrowUpDownIcon className="size-4" />
</Button>
)
}
}
@@ -231,7 +231,7 @@ const AllContainersTable = memo(function AllContainersTable({
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<table className="text-sm w-full h-full">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (
+14 -6
View File
@@ -1,21 +1,29 @@
import { useLingui } from "@lingui/react/macro"
import { Trans, 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 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>
<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>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
@@ -25,13 +25,13 @@ const passwordSchema = v.pipe(
)
const LoginSchema = v.looseObject({
company_website: honeypot,
website: honeypot,
email: emailSchema,
password: passwordSchema,
})
const RegisterSchema = v.looseObject({
company_website: honeypot,
website: honeypot,
email: emailSchema,
password: passwordSchema,
passwordConfirm: passwordSchema,
@@ -248,8 +248,19 @@ export function UserAuthForm({
)}
<div className="sr-only">
{/* honeypot */}
<label htmlFor="company_website"></label>
<input id="company_website" type="text" name="company_website" tabIndex={-1} autoComplete="off" />
<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
/>
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading ? (
@@ -305,9 +316,9 @@ export function UserAuthForm({
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
src={getAuthProviderIcon(provider)}
alt=""
// onError={(e) => {
// e.currentTarget.src = "/static/lock.svg"
// }}
// onError={(e) => {
// e.currentTarget.src = "/static/lock.svg"
// }}
/>
)}
<span className="translate-y-px">{provider.displayName}</span>
+18 -9
View File
@@ -2,19 +2,28 @@ 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 (
<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>
<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>
)
}
+51 -29
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 { t } from "@lingui/core/macro"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -49,30 +49,52 @@ 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")}>
<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>
<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>
<Trans>S.M.A.R.T.</Trans>
</TooltipContent>
</Tooltip>
<LangToggle />
<ModeToggle />
<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>
<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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
@@ -129,21 +151,21 @@ export default function Navbar() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AddSystemButton className="ms-2 hidden 450:flex" />
<AddSystemButton className="ms-2" />
</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
@@ -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, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Tooltip, TooltipContent, 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,43 +135,41 @@ 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">
<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>
)}
<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={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,
})}
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
</span>
{translatedStatus}
)}
<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]}
</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]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</TooltipContent>
)}
</Tooltip>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
@@ -186,12 +184,10 @@ export default function InfoBar({
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
) : (
content
)}
@@ -202,26 +198,24 @@ 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} />
<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>
<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>
</div>
</div>
</Card>
@@ -128,17 +128,32 @@ 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} />
{/* NOTE: change to 1 ch if switching to monospace font */}
<span className="truncate" style={{ width: `${longestName / 1.1}ch` }}>
<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")
}
}}
>
{name}
</span>
</Link>
</span>
<Link
href={getPagePath($router, "system", { id })}
href={linkUrl}
className="inset-0 absolute size-full"
aria-label={name}
></Link>
@@ -439,9 +454,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">
@@ -553,7 +568,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" }}
/>
)
}
+37 -58
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,10 +139,13 @@ 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) {
@@ -158,7 +161,6 @@ 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)
@@ -166,19 +168,6 @@ 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) {
@@ -194,24 +183,11 @@ const ChartTooltipContent = React.forwardRef<
}
if (content) {
const basePayload =
payload[0]?.payload && typeof payload[0].payload === "object"
? { ...(payload[0].payload as Record<string, unknown>) }
: {}
totalItem.payload = {
...basePayload,
[totalKey]: aggregatedNestedValues,
}
totalItem.payload = payload[0]?.payload
}
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) {
@@ -343,11 +319,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()
@@ -457,13 +433,16 @@ 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
},
]
}
+18 -20
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, TooltipProvider, TooltipTrigger } from "./tooltip"
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
return (
@@ -14,25 +14,23 @@ 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>
<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>
<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>
</div>
)
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type * as React from "react"
import { cn } from "@/lib/utils"
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
function TooltipProvider({ delayDuration = 50, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
}
+2 -1
View File
@@ -3,7 +3,7 @@ import PocketBase from "pocketbase"
import { basePath } from "@/components/router"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimes, UserSettings } from "@/types"
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
import { $alerts, $allSystemsById, $allSystemsByName, $containerAlerts, $userSettings } from "./stores"
import { chartTimeData } from "./utils"
/** PocketBase JS Client */
@@ -30,6 +30,7 @@ export function logOut() {
$allSystemsByName.set({})
$allSystemsById.set({})
$alerts.set({})
$containerAlerts.set({})
$userSettings.set({} as UserSettings)
sessionStorage.setItem("lo", "t") // prevent auto login on logout
pb.authStore.clear()
+180
View File
@@ -0,0 +1,180 @@
import { t } from "@lingui/core/macro"
import { CpuIcon, HeartPulseIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons"
import { $containerAlerts } from "@/lib/stores"
import type { ContainerAlertInfo, ContainerAlertRecord } from "@/types"
import { pb } from "./api"
/** Container alert info for each alert type */
export const containerAlertInfo: Record<string, ContainerAlertInfo> = {
Status: {
name: () => t`Status`,
unit: "",
icon: ServerIcon,
desc: () => t`Triggers when container status changes`,
singleDesc: () => `${t`Container`} ${t`Stopped`}`,
},
CPU: {
name: () => t`CPU Usage`,
unit: "%",
icon: CpuIcon,
desc: () => t`Triggers when CPU usage exceeds a threshold`,
},
Memory: {
name: () => t`Memory Usage`,
unit: "%",
icon: MemoryStickIcon,
desc: () => t`Triggers when memory usage exceeds a threshold`,
},
Network: {
name: () => t`Network`,
unit: " MB/s",
icon: EthernetIcon,
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
Health: {
name: () => t`Health Status`,
unit: "",
icon: HeartPulseIcon,
desc: () => t`Triggers when container health status changes`,
singleDesc: () => `${t`Container`} ${t`Unhealthy`}`,
},
}
class ContainerAlertManager {
private unsubscribeFn?: () => void
/**
* Subscribe to container alert updates
*/
async subscribe() {
if (this.unsubscribeFn) {
return
}
// Fetch initial container alerts
try {
const alerts = await pb.collection("container_alerts").getFullList<ContainerAlertRecord>()
this.updateStore(alerts)
} catch (e) {
console.error("Failed to fetch container alerts:", e)
}
// Subscribe to real-time updates
this.unsubscribeFn = await pb
.collection("container_alerts")
.subscribe<ContainerAlertRecord>("*", this.handleAlertUpdate)
}
/**
* Unsubscribe from container alert updates
*/
unsubscribe() {
if (this.unsubscribeFn) {
this.unsubscribeFn()
this.unsubscribeFn = undefined
}
}
/**
* Handle real-time alert updates
*/
private handleAlertUpdate = (e: RecordSubscription<ContainerAlertRecord>) => {
const { action, record } = e
if (action === "delete") {
this.deleteFromStore(record)
} else {
this.updateStore([record])
}
}
/**
* Update store with alert records
*/
private updateStore(alerts: ContainerAlertRecord[]) {
const currentAlerts = $containerAlerts.get()
for (const alert of alerts) {
if (!currentAlerts[alert.system]) {
currentAlerts[alert.system] = new Map()
}
if (!currentAlerts[alert.system].get(alert.container)) {
currentAlerts[alert.system].set(alert.container, new Map())
}
currentAlerts[alert.system].get(alert.container)!.set(alert.name, alert)
}
$containerAlerts.set(currentAlerts)
}
/**
* Delete alert from store
*/
private deleteFromStore(alert: ContainerAlertRecord) {
const currentAlerts = $containerAlerts.get()
if (currentAlerts[alert.system]?.get(alert.container)?.has(alert.name)) {
currentAlerts[alert.system].get(alert.container)!.delete(alert.name)
// Clean up empty maps
if (currentAlerts[alert.system].get(alert.container)!.size === 0) {
currentAlerts[alert.system].delete(alert.container)
}
if (currentAlerts[alert.system].size === 0) {
delete currentAlerts[alert.system]
}
$containerAlerts.set(currentAlerts)
}
}
/**
* Create or update container alerts
*/
async upsert(
systems: string[],
containers: string[],
name: string,
value: number,
min: number,
overwrite = false
) {
return pb.send("/api/beszel/user-container-alerts", {
method: "POST",
body: {
systems,
containers,
name,
value,
min,
overwrite,
},
})
}
/**
* Delete container alerts
*/
async delete(systems: string[], containers: string[], name: string) {
return pb.send("/api/beszel/user-container-alerts", {
method: "DELETE",
body: {
systems,
containers,
name,
},
})
}
/**
* Clear all container alerts from store
*/
clear() {
$containerAlerts.set({})
}
}
export const containerAlertManager = new ContainerAlertManager()
+4 -1
View File
@@ -1,5 +1,5 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import type { AlertMap, ChartTimes, ContainerAlertMap, SystemRecord, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
@@ -22,6 +22,9 @@ export const $systems: ReadableAtom<SystemRecord[]> = computed($allSystemsById,
/** Map of alert records by system id and alert name */
export const $alerts = map<AlertMap>({})
/** Map of container alert records by system id, container id, and alert name */
export const $containerAlerts = map<ContainerAlertMap>({})
/** SSH public key */
export const $publicKey = atom("")
+2 -2
View File
@@ -9,7 +9,7 @@ import {
$pausedSystems,
$upSystems,
} from "@/lib/stores"
import { updateFavicon } from "@/lib/utils"
import { getVisualStringWidth, 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, changedSystem?.name.length || 0)
const nameLen = Math.min(MAX_SYSTEM_NAME_LENGTH, getVisualStringWidth(changedSystem?.name || ""))
if (nameLen > longestName) {
$longestSystemNameLen.set(nameLen)
}
+26 -2
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,6 +429,30 @@ 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))
+4
View File
@@ -12,6 +12,7 @@ import Settings from "@/components/routes/settings/layout.tsx"
import { ThemeProvider } from "@/components/theme-provider.tsx"
import { Toaster } from "@/components/ui/toaster.tsx"
import { alertManager } from "@/lib/alerts"
import { containerAlertManager } from "@/lib/container-alerts"
import { pb, updateUserSettings } from "@/lib/api.ts"
import { dynamicActivate, getLocale } from "@/lib/i18n"
import { $authenticated, $copyContent, $direction, $publicKey, $userSettings } from "@/lib/stores.ts"
@@ -49,8 +50,11 @@ const App = memo(() => {
.then(alertManager.refresh)
// subscribe to new alert updates
.then(alertManager.subscribe)
// subscribe to container alerts
.then(() => containerAlertManager.subscribe())
return () => {
alertManager.unsubscribe()
containerAlertManager.unsubscribe()
systemsManager.unsubscribe()
}
}, [])
+31 -2
View File
@@ -215,9 +215,11 @@ interface ContainerStats {
/** memory used (gb) */
m: number
// network sent (mb)
ns: number
ns?: number
// network received (mb)
nr: number
nr?: number
/** bandwidth bytes [sent, recv] */
b?: [number, number]
}
export interface SystemStatsRecord extends RecordModel {
@@ -338,6 +340,33 @@ export interface AlertInfo {
export type AlertMap = Record<string, Map<string, AlertRecord>>
export interface ContainerAlertRecord extends RecordModel {
id: string
system: string
container: string
name: string
triggered: boolean
value: number
min: number
}
export interface ContainerAlertInfo {
name: () => string
unit: string
icon: any
desc: () => string
max?: number
min?: number
step?: number
start?: number
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
invert?: boolean
}
export type ContainerAlertMap = Record<string, Map<string, Map<string, ContainerAlertRecord>>>
export interface SmartData {
/** model family */
// mf?: string
-7
View File
@@ -9,13 +9,6 @@ SERVICE_USER=beszel
. /usr/share/debconf/confmodule
# This would normally be in the config control file, however this is currently
# broken in goreleaser. Temporarily do it here.
# https://github.com/goreleaser/goreleaser/issues/5487
db_version 2.0
db_input high beszel-agent/key || true
db_go
# Create group and user
if ! getent group "$SERVICE_USER" >/dev/null; then
echo "Creating $SERVICE_USER group"
+93 -31
View File
@@ -12,6 +12,24 @@ is_freebsd() {
[ "$(uname -s)" = "FreeBSD" ]
}
is_glibc() {
# Prefer glibc-enabled agent (NVML via purego) on linux/amd64 glibc systems.
# Check common dynamic loader paths first (fast + reliable).
for p in \
/lib64/ld-linux-x86-64.so.2 \
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 \
/lib/ld-linux-x86-64.so.2; do
[ -e "$p" ] && return 0
done
# Fallback to ldd output if available.
if command -v ldd >/dev/null 2>&1; then
ldd --version 2>&1 | grep -qiE 'gnu libc|glibc' && return 0
fi
return 1
}
# If SELinux is enabled, set the context of the binary
set_selinux_context() {
@@ -355,6 +373,20 @@ else
BIN_PATH="/opt/beszel-agent/beszel-agent"
fi
# Stop existing service if it exists (for upgrades)
if [ -f "$BIN_PATH" ]; then
echo "Existing installation detected. Stopping service for upgrade..."
if is_alpine; then
rc-service beszel-agent stop 2>/dev/null || true
elif is_openwrt; then
/etc/init.d/beszel-agent stop 2>/dev/null || true
elif is_freebsd; then
service beszel-agent stop 2>/dev/null || true
else
systemctl stop beszel-agent.service 2>/dev/null || true
fi
fi
# Uninstall process
if [ "$UNINSTALL" = true ]; then
# Clean up SELinux contexts before removing files
@@ -489,10 +521,14 @@ else
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
fi
# If no SSH key is provided, ask for the SSH key interactively
# If no SSH key is provided, ask for the SSH key interactively (skip if upgrading)
if [ -z "$KEY" ]; then
printf "Enter your SSH key: "
read KEY
if [ -f "$BIN_PATH" ]; then
echo "Upgrading existing installation. Using existing service configuration."
else
printf "Enter your SSH key: "
read KEY
fi
fi
# Remove newlines from KEY
@@ -522,7 +558,7 @@ if is_alpine; then
# Add the user to the docker group to allow access to the Docker socket if group docker exists
if getent group docker; then
echo "Adding beszel to docker group"
usermod -aG docker beszel
addgroup beszel docker
fi
elif is_openwrt; then
@@ -598,6 +634,9 @@ echo "Downloading and installing the agent..."
OS=$(uname -s | sed -e 'y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/')
ARCH=$(detect_architecture)
FILE_NAME="beszel-agent_${OS}_${ARCH}.tar.gz"
if [ "$OS" = "linux" ] && [ "$ARCH" = "amd64" ] && is_glibc; then
FILE_NAME="beszel-agent_${OS}_${ARCH}_glibc.tar.gz"
fi
# Determine version to install
if [ "$VERSION" = "latest" ]; then
@@ -646,6 +685,11 @@ if ! tar -xzf "$FILE_NAME" beszel-agent; then
exit 1
fi
if [ -f "$BIN_PATH" ]; then
echo "Backing up existing binary..."
cp "$BIN_PATH" "$BIN_PATH.bak"
fi
mv beszel-agent "$BIN_PATH"
chown beszel:beszel "$BIN_PATH"
chmod 755 "$BIN_PATH"
@@ -674,8 +718,9 @@ detect_nvidia_devices() {
# Modify service installation part, add Alpine check before systemd service creation
if is_alpine; then
echo "Creating OpenRC service for Alpine Linux..."
cat >/etc/init.d/beszel-agent <<EOF
if [ ! -f /etc/init.d/beszel-agent ]; then
echo "Creating OpenRC service for Alpine Linux..."
cat >/etc/init.d/beszel-agent <<EOF
#!/sbin/openrc-run
name="beszel-agent"
@@ -701,9 +746,11 @@ depend() {
after firewall
}
EOF
chmod +x /etc/init.d/beszel-agent
rc-update add beszel-agent default
chmod +x /etc/init.d/beszel-agent
rc-update add beszel-agent default
else
echo "Alpine OpenRC service file already exists. Skipping creation."
fi
# Create log files with proper permissions
touch /var/log/beszel-agent.log /var/log/beszel-agent.err
@@ -750,8 +797,9 @@ EOF
fi
elif is_openwrt; then
echo "Creating procd init script service for OpenWRT..."
cat >/etc/init.d/beszel-agent <<EOF
if [ ! -f /etc/init.d/beszel-agent ]; then
echo "Creating procd init script service for OpenWRT..."
cat >/etc/init.d/beszel-agent <<EOF
#!/bin/sh /etc/rc.common
USE_PROCD=1
@@ -779,10 +827,12 @@ update() {
}
EOF
# Enable the service
chmod +x /etc/init.d/beszel-agent
/etc/init.d/beszel-agent enable
# Enable the service
chmod +x /etc/init.d/beszel-agent
/etc/init.d/beszel-agent enable
else
echo "OpenWRT init script already exists. Skipping creation."
fi
# Start the service
/etc/init.d/beszel-agent restart
@@ -820,25 +870,33 @@ EOF
fi
elif is_freebsd; then
echo "Creating FreeBSD rc service..."
echo "Checking for existing FreeBSD service configuration..."
# Create environment configuration file with proper permissions
echo "Creating environment configuration file..."
cat >"$AGENT_DIR/env" <<EOF
# Create environment configuration file with proper permissions if it doesn't exist
if [ ! -f "$AGENT_DIR/env" ]; then
echo "Creating environment configuration file..."
cat >"$AGENT_DIR/env" <<EOF
LISTEN=$PORT
KEY="$KEY"
TOKEN=$TOKEN
HUB_URL=$HUB_URL
EOF
chmod 640 "$AGENT_DIR/env"
chown root:beszel "$AGENT_DIR/env"
chmod 640 "$AGENT_DIR/env"
chown root:beszel "$AGENT_DIR/env"
else
echo "FreeBSD environment file already exists. Skipping creation."
fi
# Create the rc service file
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-agent
# Create the rc service file if it doesn't exist
if [ ! -f /usr/local/etc/rc.d/beszel-agent ]; then
echo "Creating FreeBSD rc service..."
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-agent
# Set proper permissions for the rc script
chmod 755 /usr/local/etc/rc.d/beszel-agent
else
echo "FreeBSD rc service file already exists. Skipping creation."
fi
# Set proper permissions for the rc script
chmod 755 /usr/local/etc/rc.d/beszel-agent
# Enable and start the service
echo "Enabling and starting the agent service..."
sysrc beszel_agent_enable="YES"
@@ -884,12 +942,13 @@ EOF
else
# Original systemd service installation code
echo "Creating the systemd service for the agent..."
if [ ! -f /etc/systemd/system/beszel-agent.service ]; then
echo "Creating the systemd service for the agent..."
# Detect NVIDIA devices and grant device permissions
NVIDIA_DEVICES=$(detect_nvidia_devices)
# Detect NVIDIA devices and grant device permissions
NVIDIA_DEVICES=$(detect_nvidia_devices)
cat >/etc/systemd/system/beszel-agent.service <<EOF
cat >/etc/systemd/system/beszel-agent.service <<EOF
[Unit]
Description=Beszel Agent Service
Wants=network-online.target
@@ -923,12 +982,15 @@ $(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${
[Install]
WantedBy=multi-user.target
EOF
else
echo "Systemd service file already exists. Skipping creation."
fi
# Load and start the service
printf "\nLoading and starting the agent service...\n"
systemctl daemon-reload
systemctl enable beszel-agent.service
systemctl start beszel-agent.service
systemctl restart beszel-agent.service