Compare commits

...

11 Commits

Author SHA1 Message Date
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 Ginkel
c83d00ccaa Don't force lowercase text for active alerts (#1682) 2026-01-28 13:50:16 -05:00
Fahleen Arif
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
Sven van Ginkel
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 Ginkel
afcae025ae Add icon button for mobile use (#1687) 2026-01-26 20:18:17 -05:00
Matthew Stern
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 Ginkel
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
21 changed files with 514 additions and 241 deletions

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]

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)
}

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

View File

@@ -171,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")
@@ -515,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)
@@ -526,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")
@@ -829,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
@@ -867,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

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},
@@ -267,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),
@@ -516,18 +555,18 @@ func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
},
SmartDataMap: map[string]*smart.SmartData{
"serial-0": {
DiskName: "/dev/sda",
DiskType: "megaraid,0",
DiskName: "/dev/sda",
DiskType: "megaraid,0",
SerialNumber: "serial-0",
},
"serial-1": {
DiskName: "/dev/sda",
DiskType: "megaraid,1",
DiskName: "/dev/sda",
DiskType: "megaraid,1",
SerialNumber: "serial-1",
},
"serial-stale": {
DiskName: "/dev/sda",
DiskType: "megaraid,2",
DiskName: "/dev/sda",
DiskType: "megaraid,2",
SerialNumber: "serial-stale",
},
},

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
}

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"`

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
}

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")
}
}

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()
}

View File

@@ -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" ? (

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} />}

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]) => (

View File

@@ -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>

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>
)
}

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

View File

@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, 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>

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>
)
}

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} />
}

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"

View File

@@ -373,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
@@ -507,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
@@ -667,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"
@@ -695,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"
@@ -722,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
@@ -771,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
@@ -800,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
@@ -841,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"
@@ -905,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
@@ -944,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