mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-24 06:26:17 +01:00
Compare commits
14 Commits
temp-pr-16
...
20324763d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20324763d2 | ||
|
|
70f85f9590 | ||
|
|
c7f7f51c99 | ||
|
|
6723ec8ea4 | ||
|
|
afc19ebd3b | ||
|
|
c83d00ccaa | ||
|
|
425c8d2bdf | ||
|
|
42da1e5a52 | ||
|
|
afcae025ae | ||
|
|
1de36625a4 | ||
|
|
a2b6c7f5e6 | ||
|
|
799c7b077a | ||
|
|
cb5f944de6 | ||
|
|
23c4958145 |
@@ -158,9 +158,7 @@ nfpms:
|
|||||||
- debconf
|
- debconf
|
||||||
scripts:
|
scripts:
|
||||||
templates: ./supplemental/debian/templates
|
templates: ./supplemental/debian/templates
|
||||||
# Currently broken due to a bug in goreleaser
|
config: ./supplemental/debian/config.sh
|
||||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
|
||||||
#config: ./supplemental/debian/config.sh
|
|
||||||
|
|
||||||
scoops:
|
scoops:
|
||||||
- ids: [beszel-agent]
|
- ids: [beszel-agent]
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalCapacity += bat.Full
|
totalCapacity += bat.Full
|
||||||
totalCharge += bat.Current
|
totalCharge += min(bat.Current, bat.Full)
|
||||||
if bat.State.Raw >= 0 {
|
if bat.State.Raw >= 0 {
|
||||||
batteryState = uint8(bat.State.Raw)
|
batteryState = uint8(bat.State.Raw)
|
||||||
}
|
}
|
||||||
|
|||||||
25
agent/gpu.go
25
agent/gpu.go
@@ -21,6 +21,7 @@ const (
|
|||||||
// Commands
|
// Commands
|
||||||
nvidiaSmiCmd string = "nvidia-smi"
|
nvidiaSmiCmd string = "nvidia-smi"
|
||||||
rocmSmiCmd string = "rocm-smi"
|
rocmSmiCmd string = "rocm-smi"
|
||||||
|
amdgpuCmd string = "amdgpu" // internal cmd for sysfs collection
|
||||||
tegraStatsCmd string = "tegrastats"
|
tegraStatsCmd string = "tegrastats"
|
||||||
|
|
||||||
// Polling intervals
|
// Polling intervals
|
||||||
@@ -41,6 +42,7 @@ type GPUManager struct {
|
|||||||
sync.Mutex
|
sync.Mutex
|
||||||
nvidiaSmi bool
|
nvidiaSmi bool
|
||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
|
amdgpu bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
intelGpuStats bool
|
||||||
nvml bool
|
nvml bool
|
||||||
@@ -399,7 +401,13 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
gm.nvidiaSmi = true
|
gm.nvidiaSmi = true
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(rocmSmiCmd); err == nil {
|
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 {
|
if _, err := exec.LookPath(tegraStatsCmd); err == nil {
|
||||||
gm.tegrastats = true
|
gm.tegrastats = true
|
||||||
@@ -408,10 +416,10 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
gm.intelGpuStats = true
|
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 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
|
// 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.cmdArgs = []string{"--interval", tegraStatsInterval}
|
||||||
collector.parse = gm.getJetsonParser()
|
collector.parse = gm.getJetsonParser()
|
||||||
go collector.start()
|
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:
|
case rocmSmiCmd:
|
||||||
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
|
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
|
||||||
collector.parse = gm.parseAmdData
|
collector.parse = gm.parseAmdData
|
||||||
@@ -459,7 +473,7 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
if failures > maxFailureRetries {
|
if failures > maxFailureRetries {
|
||||||
break
|
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)
|
time.Sleep(rocmSmiInterval)
|
||||||
}
|
}
|
||||||
@@ -497,6 +511,9 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
if gm.rocmSmi {
|
if gm.rocmSmi {
|
||||||
gm.startCollector(rocmSmiCmd)
|
gm.startCollector(rocmSmiCmd)
|
||||||
}
|
}
|
||||||
|
if gm.amdgpu {
|
||||||
|
gm.startCollector(amdgpuCmd)
|
||||||
|
}
|
||||||
if gm.tegrastats {
|
if gm.tegrastats {
|
||||||
gm.startCollector(tegraStatsCmd)
|
gm.startCollector(tegraStatsCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
184
agent/gpu_amd_linux.go
Normal file
184
agent/gpu_amd_linux.go
Normal 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
agent/gpu_amd_unsupported.go
Normal file
15
agent/gpu_amd_unsupported.go
Normal 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
|
||||||
|
}
|
||||||
@@ -9,11 +9,31 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// healthFile is the path to the health file
|
// 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
|
// Check checks if the agent is connected by checking the modification time of the health file
|
||||||
func Check() error {
|
func Check() error {
|
||||||
@@ -30,11 +50,7 @@ func Check() error {
|
|||||||
|
|
||||||
// Update updates the modification time of the health file
|
// Update updates the modification time of the health file
|
||||||
func Update() error {
|
func Update() error {
|
||||||
file, err := os.Create(healthFile)
|
return updateHealthFile(healthFile)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return file.Close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanUp removes the health file
|
// CleanUp removes the health file
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
configuredDevices = parsedDevices
|
configuredDevices = parsedDevices
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
|
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
|
// smartctlArgs returns the arguments for the smartctl command
|
||||||
// based on the device type and whether to include standby mode
|
// based on the device type and whether to include standby mode
|
||||||
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
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 {
|
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
|
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||||
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||||
args = append(args, "-d", deviceInfo.Type)
|
args = append(args, "-d", deviceInfo.Type)
|
||||||
@@ -526,6 +528,13 @@ func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "-a", "--json=c")
|
args = append(args, "-a", "--json=c")
|
||||||
|
effectiveType := parserType
|
||||||
|
if effectiveType == "" {
|
||||||
|
effectiveType = deviceType
|
||||||
|
}
|
||||||
|
if effectiveType == "sat" || effectiveType == "ata" {
|
||||||
|
args = append(args, "-l", "devstat")
|
||||||
|
}
|
||||||
|
|
||||||
if includeStandby {
|
if includeStandby {
|
||||||
args = append(args, "-n", "standby")
|
args = append(args, "-n", "standby")
|
||||||
@@ -829,6 +838,11 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
|||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
smartData.Temperature = data.Temperature.Current
|
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.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
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) {
|
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||||
var data smart.SmartInfoForScsi
|
var data smart.SmartInfoForScsi
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
||||||
jsonPayload := []byte(`{
|
jsonPayload := []byte(`{
|
||||||
"smartctl": {"exit_status": 0},
|
"smartctl": {"exit_status": 0},
|
||||||
@@ -267,15 +300,21 @@ func TestSmartctlArgs(t *testing.T) {
|
|||||||
|
|
||||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
||||||
assert.Equal(t,
|
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),
|
sm.smartctlArgs(sataDevice, true),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.Equal(t,
|
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),
|
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,
|
assert.Equal(t,
|
||||||
[]string{"-a", "--json=c", "-n", "standby"},
|
[]string{"-a", "--json=c", "-n", "standby"},
|
||||||
sm.smartctlArgs(nil, true),
|
sm.smartctlArgs(nil, true),
|
||||||
@@ -516,18 +555,18 @@ func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
|
|||||||
},
|
},
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
SmartDataMap: map[string]*smart.SmartData{
|
||||||
"serial-0": {
|
"serial-0": {
|
||||||
DiskName: "/dev/sda",
|
DiskName: "/dev/sda",
|
||||||
DiskType: "megaraid,0",
|
DiskType: "megaraid,0",
|
||||||
SerialNumber: "serial-0",
|
SerialNumber: "serial-0",
|
||||||
},
|
},
|
||||||
"serial-1": {
|
"serial-1": {
|
||||||
DiskName: "/dev/sda",
|
DiskName: "/dev/sda",
|
||||||
DiskType: "megaraid,1",
|
DiskType: "megaraid,1",
|
||||||
SerialNumber: "serial-1",
|
SerialNumber: "serial-1",
|
||||||
},
|
},
|
||||||
"serial-stale": {
|
"serial-stale": {
|
||||||
DiskName: "/dev/sda",
|
DiskName: "/dev/sda",
|
||||||
DiskType: "megaraid,2",
|
DiskType: "megaraid,2",
|
||||||
SerialNumber: "serial-stale",
|
SerialNumber: "serial-stale",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -144,13 +144,27 @@ func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*syst
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track which units are currently present to remove stale entries
|
||||||
|
currentUnits := make(map[string]struct{}, len(units))
|
||||||
|
|
||||||
for _, unit := range units {
|
for _, unit := range units {
|
||||||
|
currentUnits[unit.Name] = struct{}{}
|
||||||
service, err := sm.updateServiceStats(conn, unit)
|
service, err := sm.updateServiceStats(conn, unit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
services = append(services, service)
|
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
|
sm.hasFreshStats = true
|
||||||
return services
|
return services
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
)
|
)
|
||||||
@@ -108,12 +106,12 @@ func Update(useMirror bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Fix SELinux context if necessary
|
// Fix SELinux context if necessary
|
||||||
if err := handleSELinuxContext(exePath); err != nil {
|
if err := ghupdate.HandleSELinuxContext(exePath); err != nil {
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
|
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 r := detectRestarter(); r != nil {
|
||||||
if err := r.Restart(); err != nil {
|
if err := r.Restart(); err != nil {
|
||||||
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
||||||
@@ -128,41 +126,3 @@ func Update(useMirror bool) error {
|
|||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -130,10 +130,23 @@ type SummaryInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AtaSmartAttributes struct {
|
type AtaSmartAttributes struct {
|
||||||
// Revision int `json:"revision"`
|
|
||||||
Table []AtaSmartAttribute `json:"table"`
|
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 {
|
type AtaSmartAttribute struct {
|
||||||
ID uint16 `json:"id"`
|
ID uint16 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -343,7 +356,8 @@ type SmartInfoForSata struct {
|
|||||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
SmartStatus SmartStatusInfo `json:"smart_status"`
|
||||||
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
|
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
|
||||||
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
|
// 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"`
|
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
|
||||||
// PowerCycleCount uint16 `json:"power_cycle_count"`
|
// PowerCycleCount uint16 `json:"power_cycle_count"`
|
||||||
Temperature TemperatureInfo `json:"temperature"`
|
Temperature TemperatureInfo `json:"temperature"`
|
||||||
|
|||||||
66
internal/ghupdate/selinux.go
Normal file
66
internal/ghupdate/selinux.go
Normal 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
internal/ghupdate/selinux_test.go
Normal file
53
internal/ghupdate/selinux_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,7 +194,34 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}
|
}
|
||||||
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
|
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
|
||||||
containersCollection.ListRule = &containersListRule
|
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
|
// registerCronJobs sets up scheduled tasks
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ func Update(cmd *cobra.Command, _ []string) {
|
|||||||
fmt.Printf("Warning: failed to set executable permissions: %v\n", err)
|
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
|
// Try to restart the service if it's running
|
||||||
restartService()
|
restartService()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ActiveAlerts = () => {
|
|||||||
>
|
>
|
||||||
<info.icon className="h-4 w-4" />
|
<info.icon className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
{systems[alert.system]?.name} {info.name()}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{alert.name === "Status" ? (
|
{alert.name === "Status" ? (
|
||||||
|
|||||||
@@ -49,10 +49,12 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
|
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 450:-ms-1" />
|
||||||
<Trans>
|
<span className="hidden 450:inline">
|
||||||
Add <span className="hidden sm:inline">System</span>
|
<Trans>
|
||||||
</Trans>
|
Add <span className="hidden sm:inline">System</span>
|
||||||
|
</Trans>
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
{opened.current && <SystemDialog setOpen={setOpen} />}
|
{opened.current && <SystemDialog setOpen={setOpen} />}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon } from "lucide-react"
|
import { LanguagesIcon } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||||
|
|
||||||
export function LangToggle() {
|
export function LangToggle() {
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
|
||||||
|
const LangTrans = <Trans>Language</Trans>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger>
|
||||||
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
|
<Tooltip>
|
||||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
<TooltipTrigger asChild>
|
||||||
<span className="sr-only">Language</span>
|
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
|
||||||
</Button>
|
<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>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="grid grid-cols-3">
|
<DropdownMenuContent className="grid grid-cols-3">
|
||||||
{languages.map(([lang, label, e]) => (
|
{languages.map(([lang, label, e]) => (
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ const passwordSchema = v.pipe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const LoginSchema = v.looseObject({
|
const LoginSchema = v.looseObject({
|
||||||
company_website: honeypot,
|
website: honeypot,
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const RegisterSchema = v.looseObject({
|
const RegisterSchema = v.looseObject({
|
||||||
company_website: honeypot,
|
website: honeypot,
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
@@ -248,8 +248,19 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="company_website"></label>
|
<label htmlFor="website"></label>
|
||||||
<input id="company_website" type="text" name="company_website" tabIndex={-1} autoComplete="off" />
|
<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>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -305,9 +316,9 @@ export function UserAuthForm({
|
|||||||
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
className="me-2 h-4 w-4 dark:brightness-0 dark:invert"
|
||||||
src={getAuthProviderIcon(provider)}
|
src={getAuthProviderIcon(provider)}
|
||||||
alt=""
|
alt=""
|
||||||
// onError={(e) => {
|
// onError={(e) => {
|
||||||
// e.currentTarget.src = "/static/lock.svg"
|
// e.currentTarget.src = "/static/lock.svg"
|
||||||
// }}
|
// }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="translate-y-px">{provider.displayName}</span>
|
<span className="translate-y-px">{provider.displayName}</span>
|
||||||
|
|||||||
@@ -2,19 +2,28 @@ import { t } from "@lingui/core/macro"
|
|||||||
import { MoonStarIcon, SunIcon } from "lucide-react"
|
import { MoonStarIcon, SunIcon } from "lucide-react"
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Tooltip>
|
||||||
variant={"ghost"}
|
<TooltipTrigger>
|
||||||
size="icon"
|
<Button
|
||||||
aria-label={t`Toggle theme`}
|
variant={"ghost"}
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
size="icon"
|
||||||
>
|
aria-label={t`Toggle theme`}
|
||||||
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
>
|
||||||
</Button>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { LangToggle } from "./lang-toggle"
|
|||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
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"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
@@ -49,30 +49,52 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
<SearchButton />
|
<SearchButton />
|
||||||
|
|
||||||
|
{/** biome-ignore lint/a11y/noStaticElementInteractions: ignore */}
|
||||||
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
||||||
<Link
|
<Tooltip>
|
||||||
href={getPagePath($router, "containers")}
|
<TooltipTrigger asChild>
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
<Link
|
||||||
aria-label="Containers"
|
href={getPagePath($router, "containers")}
|
||||||
>
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
aria-label="Containers"
|
||||||
</Link>
|
>
|
||||||
<Link
|
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
href={getPagePath($router, "smart")}
|
</Link>
|
||||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
</TooltipTrigger>
|
||||||
aria-label="S.M.A.R.T."
|
<TooltipContent>
|
||||||
>
|
<Trans>All Containers</Trans>
|
||||||
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
</TooltipContent>
|
||||||
</Link>
|
</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 />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Tooltip>
|
||||||
href={getPagePath($router, "settings", { name: "general" })}
|
<TooltipTrigger asChild>
|
||||||
aria-label="Settings"
|
<Link
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
href={getPagePath($router, "settings", { name: "general" })}
|
||||||
>
|
aria-label="Settings"
|
||||||
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
</Link>
|
>
|
||||||
|
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<Trans>Settings</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
@@ -129,21 +151,21 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AddSystemButton className="ms-2 hidden 450:flex" />
|
<AddSystemButton className="ms-2" />
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
function SearchButton() {
|
||||||
const [open, setOpen] = useState(false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
import { Separator } from "@/components/ui/separator"
|
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 { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
|
||||||
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
|
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
|
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
|
||||||
@@ -135,43 +135,41 @@ export default function InfoBar({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
|
<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">
|
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
|
||||||
<TooltipProvider>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<div className="capitalize flex gap-2 items-center">
|
||||||
<div className="capitalize flex gap-2 items-center">
|
<span className={cn("relative flex h-3 w-3")}>
|
||||||
<span className={cn("relative flex h-3 w-3")}>
|
{system.status === SystemStatus.Up && (
|
||||||
{system.status === SystemStatus.Up && (
|
|
||||||
<span
|
|
||||||
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
|
||||||
style={{ animationDuration: "1.5s" }}
|
|
||||||
></span>
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={cn("relative inline-flex rounded-full h-3 w-3", {
|
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
|
||||||
"bg-green-500": system.status === SystemStatus.Up,
|
style={{ animationDuration: "1.5s" }}
|
||||||
"bg-red-500": system.status === SystemStatus.Down,
|
|
||||||
"bg-primary/40": system.status === SystemStatus.Paused,
|
|
||||||
"bg-yellow-500": system.status === SystemStatus.Pending,
|
|
||||||
})}
|
|
||||||
></span>
|
></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>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipContent>
|
||||||
{system.info.ct && (
|
)}
|
||||||
<TooltipContent>
|
</Tooltip>
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
{system.info.ct === ConnectionType.WebSocket ? (
|
|
||||||
<WebSocketIcon className="size-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
{connectionTypeLabels[system.info.ct as ConnectionType]}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
{systemInfo.map(({ value, label, Icon, hide }) => {
|
{systemInfo.map(({ value, label, Icon, hide }) => {
|
||||||
if (hide || !value) {
|
if (hide || !value) {
|
||||||
@@ -186,12 +184,10 @@ export default function InfoBar({
|
|||||||
<div key={value} className="contents">
|
<div key={value} className="contents">
|
||||||
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
<Separator orientation="vertical" className="h-4 bg-primary/30" />
|
||||||
{label ? (
|
{label ? (
|
||||||
<TooltipProvider>
|
<Tooltip delayDuration={100}>
|
||||||
<Tooltip delayDuration={150}>
|
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
||||||
<TooltipTrigger asChild>{content}</TooltipTrigger>
|
<TooltipContent>{label}</TooltipContent>
|
||||||
<TooltipContent>{label}</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
) : (
|
||||||
content
|
content
|
||||||
)}
|
)}
|
||||||
@@ -202,26 +198,24 @@ export default function InfoBar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
||||||
<TooltipProvider delayDuration={100}>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
aria-label={t`Toggle grid`}
|
||||||
aria-label={t`Toggle grid`}
|
variant="outline"
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="icon"
|
className="hidden xl:flex p-0 text-primary"
|
||||||
className="hidden xl:flex p-0 text-primary"
|
onClick={() => setGrid(!grid)}
|
||||||
onClick={() => setGrid(!grid)}
|
>
|
||||||
>
|
{grid ? (
|
||||||
{grid ? (
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
|
||||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
|
) : (
|
||||||
) : (
|
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
|
||||||
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
||||||
<TooltipContent>{t`Toggle grid`}</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CopyIcon } from "lucide-react"
|
|||||||
import { copyToClipboard } from "@/lib/utils"
|
import { copyToClipboard } from "@/lib/utils"
|
||||||
import { Button } from "./button"
|
import { Button } from "./button"
|
||||||
import { Input } from "./input"
|
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 }) {
|
export function InputCopy({ value, id, name }: { value: string; id: string; name: string }) {
|
||||||
return (
|
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"
|
"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>
|
></div>
|
||||||
<TooltipProvider delayDuration={100} disableHoverableContent>
|
<Tooltip disableHoverableContent={true}>
|
||||||
<Tooltip disableHoverableContent={true}>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant={"link"}
|
||||||
variant={"link"}
|
className="absolute end-0 top-0"
|
||||||
className="absolute end-0 top-0"
|
onClick={() => copyToClipboard(value)}
|
||||||
onClick={() => copyToClipboard(value)}
|
>
|
||||||
>
|
<CopyIcon className="size-4" />
|
||||||
<CopyIcon className="size-4" />
|
</Button>
|
||||||
</Button>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
<TooltipContent>
|
||||||
<TooltipContent>
|
<p>
|
||||||
<p>
|
<Trans>Click to copy</Trans>
|
||||||
<Trans>Click to copy</Trans>
|
</p>
|
||||||
</p>
|
</TooltipContent>
|
||||||
</TooltipContent>
|
</Tooltip>
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
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} />
|
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ SERVICE_USER=beszel
|
|||||||
|
|
||||||
. /usr/share/debconf/confmodule
|
. /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
|
# Create group and user
|
||||||
if ! getent group "$SERVICE_USER" >/dev/null; then
|
if ! getent group "$SERVICE_USER" >/dev/null; then
|
||||||
echo "Creating $SERVICE_USER group"
|
echo "Creating $SERVICE_USER group"
|
||||||
|
|||||||
@@ -373,6 +373,20 @@ else
|
|||||||
BIN_PATH="/opt/beszel-agent/beszel-agent"
|
BIN_PATH="/opt/beszel-agent/beszel-agent"
|
||||||
fi
|
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
|
# Uninstall process
|
||||||
if [ "$UNINSTALL" = true ]; then
|
if [ "$UNINSTALL" = true ]; then
|
||||||
# Clean up SELinux contexts before removing files
|
# Clean up SELinux contexts before removing files
|
||||||
@@ -507,10 +521,14 @@ else
|
|||||||
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
|
echo "Warning: Please ensure 'tar' and 'curl' and 'sha256sum (coreutils)' are installed."
|
||||||
fi
|
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
|
if [ -z "$KEY" ]; then
|
||||||
printf "Enter your SSH key: "
|
if [ -f "$BIN_PATH" ]; then
|
||||||
read KEY
|
echo "Upgrading existing installation. Using existing service configuration."
|
||||||
|
else
|
||||||
|
printf "Enter your SSH key: "
|
||||||
|
read KEY
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove newlines from KEY
|
# Remove newlines from KEY
|
||||||
@@ -667,6 +685,11 @@ if ! tar -xzf "$FILE_NAME" beszel-agent; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -f "$BIN_PATH" ]; then
|
||||||
|
echo "Backing up existing binary..."
|
||||||
|
cp "$BIN_PATH" "$BIN_PATH.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
mv beszel-agent "$BIN_PATH"
|
mv beszel-agent "$BIN_PATH"
|
||||||
chown beszel:beszel "$BIN_PATH"
|
chown beszel:beszel "$BIN_PATH"
|
||||||
chmod 755 "$BIN_PATH"
|
chmod 755 "$BIN_PATH"
|
||||||
@@ -695,8 +718,9 @@ detect_nvidia_devices() {
|
|||||||
|
|
||||||
# Modify service installation part, add Alpine check before systemd service creation
|
# Modify service installation part, add Alpine check before systemd service creation
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
echo "Creating OpenRC service for Alpine Linux..."
|
if [ ! -f /etc/init.d/beszel-agent ]; then
|
||||||
cat >/etc/init.d/beszel-agent <<EOF
|
echo "Creating OpenRC service for Alpine Linux..."
|
||||||
|
cat >/etc/init.d/beszel-agent <<EOF
|
||||||
#!/sbin/openrc-run
|
#!/sbin/openrc-run
|
||||||
|
|
||||||
name="beszel-agent"
|
name="beszel-agent"
|
||||||
@@ -722,9 +746,11 @@ depend() {
|
|||||||
after firewall
|
after firewall
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
chmod +x /etc/init.d/beszel-agent
|
||||||
chmod +x /etc/init.d/beszel-agent
|
rc-update add beszel-agent default
|
||||||
rc-update add beszel-agent default
|
else
|
||||||
|
echo "Alpine OpenRC service file already exists. Skipping creation."
|
||||||
|
fi
|
||||||
|
|
||||||
# Create log files with proper permissions
|
# Create log files with proper permissions
|
||||||
touch /var/log/beszel-agent.log /var/log/beszel-agent.err
|
touch /var/log/beszel-agent.log /var/log/beszel-agent.err
|
||||||
@@ -771,8 +797,9 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
elif is_openwrt; then
|
elif is_openwrt; then
|
||||||
echo "Creating procd init script service for OpenWRT..."
|
if [ ! -f /etc/init.d/beszel-agent ]; then
|
||||||
cat >/etc/init.d/beszel-agent <<EOF
|
echo "Creating procd init script service for OpenWRT..."
|
||||||
|
cat >/etc/init.d/beszel-agent <<EOF
|
||||||
#!/bin/sh /etc/rc.common
|
#!/bin/sh /etc/rc.common
|
||||||
|
|
||||||
USE_PROCD=1
|
USE_PROCD=1
|
||||||
@@ -800,10 +827,12 @@ update() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
# Enable the service
|
||||||
# Enable the service
|
chmod +x /etc/init.d/beszel-agent
|
||||||
chmod +x /etc/init.d/beszel-agent
|
/etc/init.d/beszel-agent enable
|
||||||
/etc/init.d/beszel-agent enable
|
else
|
||||||
|
echo "OpenWRT init script already exists. Skipping creation."
|
||||||
|
fi
|
||||||
|
|
||||||
# Start the service
|
# Start the service
|
||||||
/etc/init.d/beszel-agent restart
|
/etc/init.d/beszel-agent restart
|
||||||
@@ -841,24 +870,32 @@ EOF
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
elif is_freebsd; then
|
elif is_freebsd; then
|
||||||
echo "Creating FreeBSD rc service..."
|
echo "Checking for existing FreeBSD service configuration..."
|
||||||
|
|
||||||
# Create environment configuration file with proper permissions
|
# Create environment configuration file with proper permissions if it doesn't exist
|
||||||
echo "Creating environment configuration file..."
|
if [ ! -f "$AGENT_DIR/env" ]; then
|
||||||
cat >"$AGENT_DIR/env" <<EOF
|
echo "Creating environment configuration file..."
|
||||||
|
cat >"$AGENT_DIR/env" <<EOF
|
||||||
LISTEN=$PORT
|
LISTEN=$PORT
|
||||||
KEY="$KEY"
|
KEY="$KEY"
|
||||||
TOKEN=$TOKEN
|
TOKEN=$TOKEN
|
||||||
HUB_URL=$HUB_URL
|
HUB_URL=$HUB_URL
|
||||||
EOF
|
EOF
|
||||||
chmod 640 "$AGENT_DIR/env"
|
chmod 640 "$AGENT_DIR/env"
|
||||||
chown root:beszel "$AGENT_DIR/env"
|
chown root:beszel "$AGENT_DIR/env"
|
||||||
|
else
|
||||||
|
echo "FreeBSD environment file already exists. Skipping creation."
|
||||||
|
fi
|
||||||
|
|
||||||
# Create the rc service file
|
# Create the rc service file if it doesn't exist
|
||||||
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-agent
|
if [ ! -f /usr/local/etc/rc.d/beszel-agent ]; then
|
||||||
|
echo "Creating FreeBSD rc service..."
|
||||||
# Set proper permissions for the rc script
|
generate_freebsd_rc_service > /usr/local/etc/rc.d/beszel-agent
|
||||||
chmod 755 /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
|
||||||
|
|
||||||
# Enable and start the service
|
# Enable and start the service
|
||||||
echo "Enabling and starting the agent service..."
|
echo "Enabling and starting the agent service..."
|
||||||
@@ -905,12 +942,13 @@ EOF
|
|||||||
|
|
||||||
else
|
else
|
||||||
# Original systemd service installation code
|
# 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
|
# Detect NVIDIA devices and grant device permissions
|
||||||
NVIDIA_DEVICES=$(detect_nvidia_devices)
|
NVIDIA_DEVICES=$(detect_nvidia_devices)
|
||||||
|
|
||||||
cat >/etc/systemd/system/beszel-agent.service <<EOF
|
cat >/etc/systemd/system/beszel-agent.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Beszel Agent Service
|
Description=Beszel Agent Service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
@@ -944,12 +982,15 @@ $(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
else
|
||||||
|
echo "Systemd service file already exists. Skipping creation."
|
||||||
|
fi
|
||||||
|
|
||||||
# Load and start the service
|
# Load and start the service
|
||||||
printf "\nLoading and starting the agent service...\n"
|
printf "\nLoading and starting the agent service...\n"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable beszel-agent.service
|
systemctl enable beszel-agent.service
|
||||||
systemctl start beszel-agent.service
|
systemctl restart beszel-agent.service
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user