diff --git a/agent/agent.go b/agent/agent.go
index 5c04e86f..e1135b25 100644
--- a/agent/agent.go
+++ b/agent/agent.go
@@ -43,6 +43,7 @@ type Agent struct {
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
+ systemdManager *systemdManager // Manages systemd services
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -101,6 +102,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
+ agent.systemdManager, err = newSystemdManager()
+ if err != nil {
+ slog.Debug("Systemd", "err", err)
+ }
+
agent.smartManager, err = NewSmartManager()
if err != nil {
slog.Debug("SMART", "err", err)
@@ -154,6 +160,11 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
}
}
+ // skip updating systemd services if cache time is not the default 60sec interval
+ if a.systemdManager != nil && cacheTimeMs == 60_000 && a.systemdManager.hasFreshStats {
+ data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
+ }
+
data.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
diff --git a/agent/client.go b/agent/client.go
index 251eea62..48a965b9 100644
--- a/agent/client.go
+++ b/agent/client.go
@@ -17,6 +17,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
@@ -276,6 +277,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
+ case systemd.ServiceDetails:
+ response.ServiceInfo = v
// case []byte:
// response.RawBytes = v
// case string:
diff --git a/agent/handlers.go b/agent/handlers.go
index 341dfdf7..931c4dfe 100644
--- a/agent/handlers.go
+++ b/agent/handlers.go
@@ -50,6 +50,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
+ registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
return registry
}
@@ -174,3 +175,31 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
data := hctx.Agent.smartManager.GetCurrentData()
return hctx.SendResponse(data, hctx.RequestID)
}
+
+////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////
+
+// GetSystemdInfoHandler handles detailed systemd service info requests
+type GetSystemdInfoHandler struct{}
+
+func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
+ if hctx.Agent.systemdManager == nil {
+ return errors.ErrUnsupported
+ }
+
+ var req common.SystemdInfoRequest
+ if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
+ return err
+ }
+ if req.ServiceName == "" {
+ return errors.New("service name is required")
+ }
+
+ details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
+ if err != nil {
+ return err
+ }
+
+ return hctx.SendResponse(details, hctx.RequestID)
+}
diff --git a/agent/server.go b/agent/server.go
index d3a4d782..c826d67f 100644
--- a/agent/server.go
+++ b/agent/server.go
@@ -15,6 +15,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
@@ -173,6 +174,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
+ case systemd.ServiceDetails:
+ response.ServiceInfo = v
default:
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
diff --git a/agent/systemd.go b/agent/systemd.go
new file mode 100644
index 00000000..da27982b
--- /dev/null
+++ b/agent/systemd.go
@@ -0,0 +1,229 @@
+//go:build linux
+
+package agent
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "maps"
+ "math"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coreos/go-systemd/v22/dbus"
+ "github.com/henrygd/beszel/internal/entities/systemd"
+)
+
+var (
+ errNoActiveTime = errors.New("no active time")
+)
+
+// systemdManager manages the collection of systemd service statistics.
+type systemdManager struct {
+ sync.Mutex
+ serviceStatsMap map[string]*systemd.Service
+ isRunning bool
+ hasFreshStats bool
+}
+
+// newSystemdManager creates a new systemdManager.
+func newSystemdManager() (*systemdManager, error) {
+ conn, err := dbus.NewSystemConnectionContext(context.Background())
+ if err != nil {
+ slog.Warn("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
+ return nil, err
+ }
+
+ manager := &systemdManager{
+ serviceStatsMap: make(map[string]*systemd.Service),
+ }
+
+ manager.startWorker(conn)
+
+ return manager, nil
+}
+
+func (sm *systemdManager) startWorker(conn *dbus.Conn) {
+ if sm.isRunning {
+ return
+ }
+ sm.isRunning = true
+ // prime the service stats map with the current services
+ _ = sm.getServiceStats(conn, true)
+ // update the services every 10 minutes
+ go func() {
+ for {
+ time.Sleep(time.Minute * 10)
+ _ = sm.getServiceStats(nil, true)
+ }
+ }()
+}
+
+// getServiceStats collects statistics for all running systemd services.
+func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
+ // start := time.Now()
+ // defer func() {
+ // slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
+ // }()
+
+ var services []*systemd.Service
+ var err error
+
+ if !refresh {
+ // return nil
+ sm.Lock()
+ defer sm.Unlock()
+ for _, service := range sm.serviceStatsMap {
+ services = append(services, service)
+ }
+ sm.hasFreshStats = false
+ return services
+ }
+
+ if conn == nil || !conn.Connected() {
+ conn, err = dbus.NewSystemConnectionContext(context.Background())
+ if err != nil {
+ return nil
+ }
+ defer conn.Close()
+ }
+
+ units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, []string{"*.service"})
+ if err != nil {
+ slog.Error("Error listing systemd service units", "err", err)
+ return nil
+ }
+
+ for _, unit := range units {
+ service, err := sm.updateServiceStats(conn, unit)
+ if err != nil {
+ continue
+ }
+ services = append(services, service)
+ }
+ sm.hasFreshStats = true
+ return services
+}
+
+// updateServiceStats updates the statistics for a single systemd service.
+func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
+ sm.Lock()
+ defer sm.Unlock()
+
+ ctx := context.Background()
+
+ // if service has never been active (no active since time), skip it
+ if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
+ if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
+ return nil, errNoActiveTime
+ }
+ } else {
+ return nil, err
+ }
+
+ service, serviceExists := sm.serviceStatsMap[unit.Name]
+ if !serviceExists {
+ service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
+ sm.serviceStatsMap[unit.Name] = service
+ }
+
+ memPeak := service.MemPeak
+ if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
+ // If memPeak is MaxUint64 the api is saying it's not available
+ if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
+ memPeak = v
+ }
+ }
+
+ var memUsage uint64
+ if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
+ // If memUsage is MaxUint64 the api is saying it's not available
+ if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
+ memUsage = v
+ }
+ }
+
+ service.State = systemd.ParseServiceStatus(unit.ActiveState)
+ service.Sub = systemd.ParseServiceSubState(unit.SubState)
+
+ // some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
+ if memUsage > memPeak {
+ memPeak = memUsage
+ }
+
+ var cpuUsage uint64
+ if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
+ if v, ok := cpuProp.Value.Value().(uint64); ok {
+ cpuUsage = v
+ }
+ }
+
+ service.Mem = memUsage
+ if memPeak > service.MemPeak {
+ service.MemPeak = memPeak
+ }
+ service.UpdateCPUPercent(cpuUsage)
+
+ return service, nil
+}
+
+// getServiceDetails collects extended information for a specific systemd service.
+func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
+ conn, err := dbus.NewSystemConnectionContext(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+
+ unitName := serviceName
+ if !strings.HasSuffix(unitName, ".service") {
+ unitName += ".service"
+ }
+
+ ctx := context.Background()
+ props, err := conn.GetUnitPropertiesContext(ctx, unitName)
+ if err != nil {
+ return nil, err
+ }
+
+ // Start with all unit properties
+ details := make(systemd.ServiceDetails)
+ maps.Copy(details, props)
+
+ // // Add service-specific properties
+ servicePropNames := []string{
+ "MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
+ "MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
+ "NRestarts", "ExecMainStartTimestampRealtime", "Result",
+ }
+
+ for _, propName := range servicePropNames {
+ if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
+ value := variant.Value.Value()
+ // Check if the value is MaxUint64, which indicates unlimited/infinite
+ if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
+ // Set to nil to indicate unlimited - frontend will handle this appropriately
+ details[propName] = nil
+ } else {
+ details[propName] = value
+ }
+ }
+ }
+
+ return details, nil
+}
+
+// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
+func unescapeServiceName(name string) string {
+ if !strings.Contains(name, "\\x") {
+ return name
+ }
+ unescaped, err := strconv.Unquote("\"" + name + "\"")
+ if err != nil {
+ return name
+ }
+ return unescaped
+}
diff --git a/agent/systemd_nonlinux.go b/agent/systemd_nonlinux.go
new file mode 100644
index 00000000..2aaf58c5
--- /dev/null
+++ b/agent/systemd_nonlinux.go
@@ -0,0 +1,28 @@
+//go:build !linux
+
+package agent
+
+import (
+ "errors"
+
+ "github.com/henrygd/beszel/internal/entities/systemd"
+)
+
+// systemdManager manages the collection of systemd service statistics.
+type systemdManager struct {
+ hasFreshStats bool
+}
+
+// newSystemdManager creates a new systemdManager.
+func newSystemdManager() (*systemdManager, error) {
+ return &systemdManager{}, nil
+}
+
+// getServiceStats returns nil for non-linux systems.
+func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
+ return nil
+}
+
+func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
+ return nil, errors.New("systemd manager unavailable")
+}
diff --git a/agent/systemd_nonlinux_test.go b/agent/systemd_nonlinux_test.go
new file mode 100644
index 00000000..af4cda9e
--- /dev/null
+++ b/agent/systemd_nonlinux_test.go
@@ -0,0 +1,53 @@
+//go:build !linux && testing
+
+package agent
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewSystemdManager(t *testing.T) {
+ manager, err := newSystemdManager()
+ assert.NoError(t, err)
+ assert.NotNil(t, manager)
+}
+
+func TestSystemdManagerGetServiceStats(t *testing.T) {
+ manager, err := newSystemdManager()
+ assert.NoError(t, err)
+
+ // Test with refresh = true
+ result := manager.getServiceStats(true)
+ assert.Nil(t, result)
+
+ // Test with refresh = false
+ result = manager.getServiceStats(false)
+ assert.Nil(t, result)
+}
+
+func TestSystemdManagerGetServiceDetails(t *testing.T) {
+ manager, err := newSystemdManager()
+ assert.NoError(t, err)
+
+ result, err := manager.getServiceDetails("any-service")
+ assert.Error(t, err)
+ assert.Equal(t, "systemd manager unavailable", err.Error())
+ assert.Nil(t, result)
+
+ // Test with empty service name
+ result, err = manager.getServiceDetails("")
+ assert.Error(t, err)
+ assert.Equal(t, "systemd manager unavailable", err.Error())
+ assert.Nil(t, result)
+}
+
+func TestSystemdManagerFields(t *testing.T) {
+ manager, err := newSystemdManager()
+ assert.NoError(t, err)
+
+ // The non-linux manager should be a simple struct with no special fields
+ // We can't test private fields directly, but we can test the methods work
+ assert.NotNil(t, manager)
+}
diff --git a/agent/systemd_test.go b/agent/systemd_test.go
new file mode 100644
index 00000000..980feefc
--- /dev/null
+++ b/agent/systemd_test.go
@@ -0,0 +1,48 @@
+//go:build linux && testing
+
+package agent
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUnescapeServiceName(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"nginx.service", "nginx.service"}, // No escaping needed
+ {"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
+ {"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
+ {"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
+ {"no-escape-here", "no-escape-here"}, // No escape sequences
+ {"", ""}, // Empty string
+ {"\\x2d\\x2d", "--"}, // Multiple escapes
+ }
+
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ result := unescapeServiceName(test.input)
+ assert.Equal(t, test.expected, result)
+ })
+ }
+}
+
+func TestUnescapeServiceNameInvalid(t *testing.T) {
+ // Test invalid escape sequences - should return original string
+ invalidInputs := []string{
+ "invalid\\x", // Incomplete escape
+ "invalid\\xZZ", // Invalid hex
+ "invalid\\x2", // Incomplete hex
+ "invalid\\xyz", // Not a valid escape
+ }
+
+ for _, input := range invalidInputs {
+ t.Run(input, func(t *testing.T) {
+ result := unescapeServiceName(input)
+ assert.Equal(t, input, result, "Invalid escape sequences should return original string")
+ })
+ }
+}
diff --git a/beszel.go b/beszel.go
index 0dc2dd11..4a0264b2 100644
--- a/beszel.go
+++ b/beszel.go
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
- Version = "0.15.4"
+ Version = "0.16.0-beta.1"
// AppName is the name of the application.
AppName = "beszel"
)
diff --git a/go.mod b/go.mod
index f132d7fc..cc43da35 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.25.3
require (
github.com/blang/semver v3.5.1+incompatible
+ github.com/coreos/go-systemd/v22 v22.6.0
github.com/distatus/battery v0.11.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
@@ -37,6 +38,7 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.1 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
diff --git a/go.sum b/go.sum
index 4c74e7bb..07608dc4 100644
--- a/go.sum
+++ b/go.sum
@@ -9,6 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
+github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
+github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -49,6 +51,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go
index 64e96830..290e0dbb 100644
--- a/internal/common/common-ws.go
+++ b/internal/common/common-ws.go
@@ -3,6 +3,7 @@ package common
import (
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/systemd"
)
type WebSocketAction = uint8
@@ -18,6 +19,8 @@ const (
GetContainerInfo
// Request SMART data from agent
GetSmartData
+ // Request detailed systemd service info from agent
+ GetSystemdInfo
// Add new actions here...
)
@@ -36,6 +39,7 @@ type AgentResponse struct {
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"`
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
+ ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
}
@@ -65,3 +69,7 @@ type ContainerLogsRequest struct {
type ContainerInfoRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
+
+type SystemdInfoRequest struct {
+ ServiceName string `cbor:"0,keyasint"`
+}
diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go
index cf9df3bd..b467d892 100644
--- a/internal/entities/system/system.go
+++ b/internal/entities/system/system.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/container"
+ "github.com/henrygd/beszel/internal/entities/systemd"
)
type Stats struct {
@@ -149,7 +150,8 @@ type Info struct {
// Final data structure to return to the hub
type CombinedData struct {
- Stats Stats `json:"stats" cbor:"0,keyasint"`
- Info Info `json:"info" cbor:"1,keyasint"`
- Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
+ Stats Stats `json:"stats" cbor:"0,keyasint"`
+ Info Info `json:"info" cbor:"1,keyasint"`
+ Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
+ SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
}
diff --git a/internal/entities/systemd/systemd.go b/internal/entities/systemd/systemd.go
new file mode 100644
index 00000000..1df9a12a
--- /dev/null
+++ b/internal/entities/systemd/systemd.go
@@ -0,0 +1,127 @@
+package systemd
+
+import (
+ "math"
+ "runtime"
+ "time"
+)
+
+// ServiceState represents the status of a systemd service
+type ServiceState uint8
+
+const (
+ StatusActive ServiceState = iota
+ StatusInactive
+ StatusFailed
+ StatusActivating
+ StatusDeactivating
+ StatusReloading
+)
+
+// ServiceSubState represents the sub status of a systemd service
+type ServiceSubState uint8
+
+const (
+ SubStateDead ServiceSubState = iota
+ SubStateRunning
+ SubStateExited
+ SubStateFailed
+ SubStateUnknown
+)
+
+// ParseServiceStatus converts a string status to a ServiceStatus enum value
+func ParseServiceStatus(status string) ServiceState {
+ switch status {
+ case "active":
+ return StatusActive
+ case "inactive":
+ return StatusInactive
+ case "failed":
+ return StatusFailed
+ case "activating":
+ return StatusActivating
+ case "deactivating":
+ return StatusDeactivating
+ case "reloading":
+ return StatusReloading
+ default:
+ return StatusInactive
+ }
+}
+
+// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
+func ParseServiceSubState(subState string) ServiceSubState {
+ switch subState {
+ case "dead":
+ return SubStateDead
+ case "running":
+ return SubStateRunning
+ case "exited":
+ return SubStateExited
+ case "failed":
+ return SubStateFailed
+ default:
+ return SubStateUnknown
+ }
+}
+
+// Service represents a single systemd service with its stats.
+type Service struct {
+ Name string `json:"n" cbor:"0,keyasint"`
+ State ServiceState `json:"s" cbor:"1,keyasint"`
+ Cpu float64 `json:"c" cbor:"2,keyasint"`
+ Mem uint64 `json:"m" cbor:"3,keyasint"`
+ MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
+ Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
+ CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
+ PrevCpuUsage uint64 `json:"-"`
+ PrevReadTime time.Time `json:"-"`
+}
+
+// UpdateCPUPercent calculates the CPU usage percentage for the service.
+func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
+ now := time.Now()
+
+ if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
+ s.Cpu = 0
+ s.PrevCpuUsage = cpuUsage
+ s.PrevReadTime = now
+ return
+ }
+
+ duration := now.Sub(s.PrevReadTime).Nanoseconds()
+ if duration <= 0 {
+ s.PrevCpuUsage = cpuUsage
+ s.PrevReadTime = now
+ return
+ }
+
+ coreCount := int64(runtime.NumCPU())
+ duration *= coreCount
+
+ usageDelta := cpuUsage - s.PrevCpuUsage
+ cpuPercent := float64(usageDelta) / float64(duration)
+ s.Cpu = twoDecimals(cpuPercent * 100)
+
+ if s.Cpu > s.CpuPeak {
+ s.CpuPeak = s.Cpu
+ }
+
+ s.PrevCpuUsage = cpuUsage
+ s.PrevReadTime = now
+}
+
+func twoDecimals(value float64) float64 {
+ return math.Round(value*100) / 100
+}
+
+// ServiceDependency represents a unit that the service depends on.
+type ServiceDependency struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ ActiveState string `json:"activeState,omitempty"`
+ SubState string `json:"subState,omitempty"`
+}
+
+// ServiceDetails contains extended information about a systemd service.
+type ServiceDetails map[string]any
diff --git a/internal/entities/systemd/systemd_test.go b/internal/entities/systemd/systemd_test.go
new file mode 100644
index 00000000..8e5ceb10
--- /dev/null
+++ b/internal/entities/systemd/systemd_test.go
@@ -0,0 +1,113 @@
+//go:build testing
+
+package systemd_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/henrygd/beszel/internal/entities/systemd"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestParseServiceStatus(t *testing.T) {
+ tests := []struct {
+ input string
+ expected systemd.ServiceState
+ }{
+ {"active", systemd.StatusActive},
+ {"inactive", systemd.StatusInactive},
+ {"failed", systemd.StatusFailed},
+ {"activating", systemd.StatusActivating},
+ {"deactivating", systemd.StatusDeactivating},
+ {"reloading", systemd.StatusReloading},
+ {"unknown", systemd.StatusInactive}, // default case
+ {"", systemd.StatusInactive}, // default case
+ }
+
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ result := systemd.ParseServiceStatus(test.input)
+ assert.Equal(t, test.expected, result)
+ })
+ }
+}
+
+func TestParseServiceSubState(t *testing.T) {
+ tests := []struct {
+ input string
+ expected systemd.ServiceSubState
+ }{
+ {"dead", systemd.SubStateDead},
+ {"running", systemd.SubStateRunning},
+ {"exited", systemd.SubStateExited},
+ {"failed", systemd.SubStateFailed},
+ {"unknown", systemd.SubStateUnknown},
+ {"other", systemd.SubStateUnknown}, // default case
+ {"", systemd.SubStateUnknown}, // default case
+ }
+
+ for _, test := range tests {
+ t.Run(test.input, func(t *testing.T) {
+ result := systemd.ParseServiceSubState(test.input)
+ assert.Equal(t, test.expected, result)
+ })
+ }
+}
+
+func TestServiceUpdateCPUPercent(t *testing.T) {
+ t.Run("initial call sets CPU to 0", func(t *testing.T) {
+ service := &systemd.Service{}
+ service.UpdateCPUPercent(1000)
+ assert.Equal(t, 0.0, service.Cpu)
+ assert.Equal(t, uint64(1000), service.PrevCpuUsage)
+ assert.False(t, service.PrevReadTime.IsZero())
+ })
+
+ t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
+ service := &systemd.Service{}
+ service.PrevCpuUsage = 1000
+ service.PrevReadTime = time.Now().Add(-time.Second)
+
+ service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
+
+ // CPU usage should be positive and reasonable
+ assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
+ assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
+ assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
+ assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
+ })
+
+ t.Run("CPU peak updates only when higher", func(t *testing.T) {
+ service := &systemd.Service{}
+ service.PrevCpuUsage = 1000
+ service.PrevReadTime = time.Now().Add(-time.Second)
+ service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
+ initialPeak := service.CpuPeak
+
+ // Now try with much lower CPU usage - should not update peak
+ service.PrevReadTime = time.Now().Add(-time.Second)
+ service.UpdateCPUPercent(1000000) // Much lower usage
+ assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
+ })
+
+ t.Run("handles zero duration", func(t *testing.T) {
+ service := &systemd.Service{}
+ service.PrevCpuUsage = 1000
+ now := time.Now()
+ service.PrevReadTime = now
+ // Mock time.Now() to return the same time to ensure zero duration
+ // Since we can't mock time in Go easily, we'll check the logic manually
+ // The zero duration case happens when duration <= 0
+ assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
+ })
+
+ t.Run("handles CPU usage wraparound", func(t *testing.T) {
+ service := &systemd.Service{}
+ // Simulate wraparound where new usage is less than previous
+ service.PrevCpuUsage = 1000
+ service.PrevReadTime = time.Now().Add(-time.Second)
+ service.UpdateCPUPercent(500) // Less than previous, should reset
+ assert.Equal(t, 0.0, service.Cpu)
+ })
+}
diff --git a/internal/hub/hub.go b/internal/hub/hub.go
index c9dc2713..ec0d2839 100644
--- a/internal/hub/hub.go
+++ b/internal/hub/hub.go
@@ -270,6 +270,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// get SMART data
apiAuth.GET("/smart", h.getSmartData)
+ // get systemd service details
+ apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -342,6 +344,27 @@ func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
}, "info")
}
+// getSystemdInfo handles GET /api/beszel/systemd/info requests
+func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
+ query := e.Request.URL.Query()
+ systemID := query.Get("system")
+ serviceName := query.Get("service")
+
+ if systemID == "" || serviceName == "" {
+ return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
+ }
+ system, err := h.sm.GetSystem(systemID)
+ if err != nil {
+ return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
+ }
+ details, err := system.FetchSystemdInfoFromAgent(serviceName)
+ if err != nil {
+ return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
+ }
+ e.Response.Header().Set("Cache-Control", "public, max-age=60")
+ return e.JSON(http.StatusOK, map[string]any{"details": details})
+}
+
// getSmartData handles GET /api/beszel/smart requests
func (h *Hub) getSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go
index cdba4f92..8ca5dbf9 100644
--- a/internal/hub/systems/system.go
+++ b/internal/hub/systems/system.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "hash/fnv"
"math/rand"
"net"
"strings"
@@ -15,6 +16,7 @@ import (
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel"
@@ -171,6 +173,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return err
}
}
+
+ // add new systemd_stats record
+ if len(data.SystemdServices) > 0 {
+ if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
+ return err
+ }
+ }
+
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
@@ -184,11 +194,50 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return systemRecord, err
}
+func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
+ if len(data) == 0 {
+ return nil
+ }
+ // shared params for all records
+ params := dbx.Params{
+ "system": systemId,
+ "updated": time.Now().UTC().UnixMilli(),
+ }
+
+ valueStrings := make([]string, 0, len(data))
+ for i, service := range data {
+ suffix := fmt.Sprintf("%d", i)
+ valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
+ params["id"+suffix] = getSystemdServiceId(systemId, service.Name)
+ params["name"+suffix] = service.Name
+ params["state"+suffix] = service.State
+ params["sub"+suffix] = service.Sub
+ params["cpu"+suffix] = service.Cpu
+ params["cpuPeak"+suffix] = service.CpuPeak
+ params["memory"+suffix] = service.Mem
+ params["memPeak"+suffix] = service.MemPeak
+ }
+ queryString := fmt.Sprintf(
+ "INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
+ strings.Join(valueStrings, ","),
+ )
+ _, err := app.DB().NewQuery(queryString).Bind(params).Execute()
+ return err
+}
+
+// getSystemdServiceId generates a deterministic unique id for a systemd service
+func getSystemdServiceId(systemId string, serviceName string) string {
+ hash := fnv.New32a()
+ hash.Write([]byte(systemId + serviceName))
+ return fmt.Sprintf("%x", hash.Sum32())
+}
+
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
return nil
}
+ // shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
@@ -340,6 +389,52 @@ func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, erro
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
+// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
+func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
+ // fetch via websocket
+ if sys.WsConn != nil && sys.WsConn.IsConnected() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
+ }
+
+ var result systemd.ServiceDetails
+ err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
+ stdout, err := session.StdoutPipe()
+ if err != nil {
+ return false, err
+ }
+ stdin, stdinErr := session.StdinPipe()
+ if stdinErr != nil {
+ return false, stdinErr
+ }
+ if err := session.Shell(); err != nil {
+ return false, err
+ }
+
+ req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
+ if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
+ return false, err
+ }
+ _ = stdin.Close()
+
+ var resp common.AgentResponse
+ if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
+ return false, err
+ }
+ if resp.ServiceInfo == nil {
+ if resp.Error != "" {
+ return false, errors.New(resp.Error)
+ }
+ return false, errors.New("no systemd info in response")
+ }
+ result = resp.ServiceInfo
+ return false, nil
+ })
+
+ return result, err
+}
+
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
// fetch via websocket
diff --git a/internal/hub/systems/system_systemd_test.go b/internal/hub/systems/system_systemd_test.go
new file mode 100644
index 00000000..41ab494b
--- /dev/null
+++ b/internal/hub/systems/system_systemd_test.go
@@ -0,0 +1,75 @@
+//go:build testing
+
+package systems
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetSystemdServiceId(t *testing.T) {
+ t.Run("deterministic output", func(t *testing.T) {
+ systemId := "sys-123"
+ serviceName := "nginx.service"
+
+ // Call multiple times and ensure same result
+ id1 := getSystemdServiceId(systemId, serviceName)
+ id2 := getSystemdServiceId(systemId, serviceName)
+ id3 := getSystemdServiceId(systemId, serviceName)
+
+ assert.Equal(t, id1, id2)
+ assert.Equal(t, id2, id3)
+ assert.NotEmpty(t, id1)
+ })
+
+ t.Run("different inputs produce different ids", func(t *testing.T) {
+ systemId1 := "sys-123"
+ systemId2 := "sys-456"
+ serviceName1 := "nginx.service"
+ serviceName2 := "apache.service"
+
+ id1 := getSystemdServiceId(systemId1, serviceName1)
+ id2 := getSystemdServiceId(systemId2, serviceName1)
+ id3 := getSystemdServiceId(systemId1, serviceName2)
+ id4 := getSystemdServiceId(systemId2, serviceName2)
+
+ // All IDs should be different
+ assert.NotEqual(t, id1, id2)
+ assert.NotEqual(t, id1, id3)
+ assert.NotEqual(t, id1, id4)
+ assert.NotEqual(t, id2, id3)
+ assert.NotEqual(t, id2, id4)
+ assert.NotEqual(t, id3, id4)
+ })
+
+ t.Run("consistent length", func(t *testing.T) {
+ testCases := []struct {
+ systemId string
+ serviceName string
+ }{
+ {"short", "short.service"},
+ {"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
+ {"", "empty-system.service"},
+ {"empty-service", ""},
+ {"", ""},
+ }
+
+ for _, tc := range testCases {
+ id := getSystemdServiceId(tc.systemId, tc.serviceName)
+ // FNV-32 produces 8 hex characters
+ assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
+ }
+ })
+
+ t.Run("hexadecimal output", func(t *testing.T) {
+ id := getSystemdServiceId("test-system", "test-service")
+ assert.NotEmpty(t, id)
+
+ // Should only contain hexadecimal characters
+ for _, char := range id {
+ assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
+ "ID should only contain hexadecimal characters, got: %s", id)
+ }
+ })
+}
diff --git a/internal/hub/ws/handlers.go b/internal/hub/ws/handlers.go
index f879f53a..5d07fe6f 100644
--- a/internal/hub/ws/handlers.go
+++ b/internal/hub/ws/handlers.go
@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
+ "github.com/henrygd/beszel/internal/entities/systemd"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
@@ -115,6 +116,44 @@ func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string)
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
+// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
+func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
+ if !ws.IsConnected() {
+ return nil, gws.ErrConnClosed
+ }
+
+ req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
+ if err != nil {
+ return nil, err
+ }
+
+ var result systemd.ServiceDetails
+ handler := &systemdInfoHandler{result: &result}
+ if err := ws.handleAgentRequest(req, handler); err != nil {
+ return nil, err
+ }
+
+ return result, nil
+}
+
+// systemdInfoHandler parses ServiceDetails from AgentResponse
+type systemdInfoHandler struct {
+ BaseHandler
+ result *systemd.ServiceDetails
+}
+
+func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
+ if agentResponse.ServiceInfo == nil {
+ return errors.New("no systemd info in response")
+ }
+ *h.result = agentResponse.ServiceInfo
+ return nil
+}
+
+////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////
+////////////////////////////////////////////////////////////////////////////
+
// RequestSmartData requests SMART data via WebSocket.
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
if !ws.IsConnected() {
diff --git a/internal/hub/ws/handlers_test.go b/internal/hub/ws/handlers_test.go
new file mode 100644
index 00000000..0ca31d94
--- /dev/null
+++ b/internal/hub/ws/handlers_test.go
@@ -0,0 +1,75 @@
+//go:build testing
+
+package ws
+
+import (
+ "testing"
+
+ "github.com/henrygd/beszel/internal/common"
+ "github.com/henrygd/beszel/internal/entities/systemd"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSystemdInfoHandlerSuccess(t *testing.T) {
+ handler := &systemdInfoHandler{
+ result: &systemd.ServiceDetails{},
+ }
+
+ // Test successful handling with valid ServiceInfo
+ testDetails := systemd.ServiceDetails{
+ "Id": "nginx.service",
+ "ActiveState": "active",
+ "SubState": "running",
+ "Description": "A high performance web server",
+ "ExecMainPID": 1234,
+ "MemoryCurrent": 1024000,
+ }
+
+ response := common.AgentResponse{
+ ServiceInfo: testDetails,
+ }
+
+ err := handler.Handle(response)
+ assert.NoError(t, err)
+ assert.Equal(t, testDetails, *handler.result)
+}
+
+func TestSystemdInfoHandlerError(t *testing.T) {
+ handler := &systemdInfoHandler{
+ result: &systemd.ServiceDetails{},
+ }
+
+ // Test error handling when ServiceInfo is nil
+ response := common.AgentResponse{
+ ServiceInfo: nil,
+ Error: "service not found",
+ }
+
+ err := handler.Handle(response)
+ assert.Error(t, err)
+ assert.Equal(t, "no systemd info in response", err.Error())
+}
+
+func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
+ handler := &systemdInfoHandler{
+ result: &systemd.ServiceDetails{},
+ }
+
+ // Test with completely empty response
+ response := common.AgentResponse{}
+
+ err := handler.Handle(response)
+ assert.Error(t, err)
+ assert.Equal(t, "no systemd info in response", err.Error())
+}
+
+func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
+ handler := &systemdInfoHandler{
+ result: &systemd.ServiceDetails{},
+ }
+
+ // Test that legacy format is not supported
+ err := handler.HandleLegacy([]byte("some data"))
+ assert.Error(t, err)
+ assert.Equal(t, "legacy format not supported", err.Error())
+}
diff --git a/internal/migrations/0_collections_snapshot_0_14_1.go b/internal/migrations/0_collections_snapshot_0_16_0.go
similarity index 88%
rename from internal/migrations/0_collections_snapshot_0_14_1.go
rename to internal/migrations/0_collections_snapshot_0_16_0.go
index b04c4c94..a90a011a 100644
--- a/internal/migrations/0_collections_snapshot_0_14_1.go
+++ b/internal/migrations/0_collections_snapshot_0_16_0.go
@@ -1007,6 +1007,148 @@ func init() {
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
+ },
+ {
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{10}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 10,
+ "min": 6,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text1579384326",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": true,
+ "collectionId": "2hz5ncl8tizk5nx",
+ "hidden": false,
+ "id": "relation3377271179",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "system",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "hidden": false,
+ "id": "number2063623452",
+ "max": null,
+ "min": null,
+ "name": "state",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number1476559580",
+ "max": null,
+ "min": null,
+ "name": "sub",
+ "onlyInt": true,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number3128971310",
+ "max": null,
+ "min": null,
+ "name": "cpu",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number1052053287",
+ "max": null,
+ "min": null,
+ "name": "cpuPeak",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number3933025333",
+ "max": null,
+ "min": null,
+ "name": "memory",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number1828797201",
+ "max": null,
+ "min": null,
+ "name": "memPeak",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "number3332085495",
+ "max": null,
+ "min": null,
+ "name": "updated",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ }
+ ],
+ "id": "pbc_3494996990",
+ "indexes": [
+ "CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
+ "CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
+ ],
+ "listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
+ "name": "systemd_services",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
}
]`
diff --git a/internal/migrations/1758738789_fix_cached_mem.go b/internal/migrations/1758738789_fix_cached_mem.go
deleted file mode 100644
index 63e2ddc9..00000000
--- a/internal/migrations/1758738789_fix_cached_mem.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package migrations
-
-import (
- "github.com/henrygd/beszel/internal/entities/system"
- "github.com/pocketbase/pocketbase/core"
- m "github.com/pocketbase/pocketbase/migrations"
-)
-
-// This can be deleted after Nov 2025 or so
-
-func init() {
- m.Register(func(app core.App) error {
- app.RunInTransaction(func(txApp core.App) error {
- var systemIds []string
- txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
-
- for _, systemId := range systemIds {
- var statRecordIds []string
- txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
-
- for _, statRecordId := range statRecordIds {
- statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
- if err != nil {
- return err
- }
- var systemStats system.Stats
- err = statRecord.UnmarshalJSONField("stats", &systemStats)
- if err != nil {
- return err
- }
- // if mem buff cache is less than total mem, we don't need to fix it
- if systemStats.MemBuffCache < systemStats.Mem {
- continue
- }
- systemStats.MemBuffCache = 0
- statRecord.Set("stats", systemStats)
- err = txApp.SaveNoValidate(statRecord)
- if err != nil {
- return err
- }
- }
- }
-
- return nil
- })
- return nil
- }, func(app core.App) error {
- return nil
- })
-}
diff --git a/internal/records/records.go b/internal/records/records.go
index 173f3c76..1b2c42e6 100644
--- a/internal/records/records.go
+++ b/internal/records/records.go
@@ -490,6 +490,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil {
return err
}
+ err = deleteOldSystemdServiceRecords(txApp)
+ if err != nil {
+ return err
+ }
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
@@ -559,6 +563,20 @@ func deleteOldSystemStats(app core.App) error {
return nil
}
+// Deletes systemd service records that haven't been updated in the last 20 minutes
+func deleteOldSystemdServiceRecords(app core.App) error {
+ now := time.Now().UTC()
+ twentyMinutesAgo := now.Add(-20 * time.Minute)
+
+ // Delete systemd service records where updated < twentyMinutesAgo
+ _, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
+ if err != nil {
+ return fmt.Errorf("failed to delete old systemd service records: %v", err)
+ }
+
+ return nil
+}
+
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()
diff --git a/internal/records/records_test.go b/internal/records/records_test.go
index a2cd2de5..b93ecf75 100644
--- a/internal/records/records_test.go
+++ b/internal/records/records_test.go
@@ -351,6 +351,83 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
})
}
+// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
+func TestDeleteOldSystemdServiceRecords(t *testing.T) {
+ hub, err := tests.NewTestHub(t.TempDir())
+ require.NoError(t, err)
+ defer hub.Cleanup()
+
+ rm := records.NewRecordManager(hub)
+
+ // Create test user and system
+ user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
+ require.NoError(t, err)
+
+ system, err := tests.CreateRecord(hub, "systems", map[string]any{
+ "name": "test-system",
+ "host": "localhost",
+ "port": "45876",
+ "status": "up",
+ "users": []string{user.Id},
+ })
+ require.NoError(t, err)
+
+ now := time.Now().UTC()
+
+ // Create old systemd service records that should be deleted (older than 20 minutes)
+ oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
+ "system": system.Id,
+ "name": "nginx.service",
+ "state": 0, // Active
+ "sub": 1, // Running
+ "cpu": 5.0,
+ "cpuPeak": 10.0,
+ "memory": 1024000,
+ "memPeak": 2048000,
+ })
+ require.NoError(t, err)
+ // Set updated time to 25 minutes ago (should be deleted)
+ oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
+ err = hub.SaveNoValidate(oldRecord)
+ require.NoError(t, err)
+
+ // Create recent systemd service record that should be kept (within 20 minutes)
+ recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
+ "system": system.Id,
+ "name": "apache.service",
+ "state": 1, // Inactive
+ "sub": 0, // Dead
+ "cpu": 2.0,
+ "cpuPeak": 3.0,
+ "memory": 512000,
+ "memPeak": 1024000,
+ })
+ require.NoError(t, err)
+ // Set updated time to 10 minutes ago (should be kept)
+ recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
+ err = hub.SaveNoValidate(recentRecord)
+ require.NoError(t, err)
+
+ // Count records before deletion
+ countBefore, err := hub.CountRecords("systemd_services")
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
+
+ // Run deletion via RecordManager
+ rm.DeleteOldRecords()
+
+ // Count records after deletion
+ countAfter, err := hub.CountRecords("systemd_services")
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
+
+ // Verify the correct record was kept
+ remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
+ require.NoError(t, err)
+ assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
+ assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
+}
+
// TestRecordManagerCreation tests RecordManager creation
func TestRecordManagerCreation(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
diff --git a/internal/site/src/components/charts/container-chart.tsx b/internal/site/src/components/charts/container-chart.tsx
index 0b301375..d1312d4d 100644
--- a/internal/site/src/components/charts/container-chart.tsx
+++ b/internal/site/src/components/charts/container-chart.tsx
@@ -41,7 +41,7 @@ export default memo(function ContainerChart({
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
- const val = toFixedFloat(value, 2) + unit
+ const val = `${toFixedFloat(value, 2)}%`
return updateYAxisWidth(val)
}
} else {
@@ -78,7 +78,7 @@ export default memo(function ContainerChart({
return `${decimalString(value)} ${unit}`
}
} else {
- obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
+ obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
}
// data function
if (isNetChart) {
diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx
index fd7a1d01..77cb4fcf 100644
--- a/internal/site/src/components/routes/system.tsx
+++ b/internal/site/src/components/routes/system.tsx
@@ -75,8 +75,6 @@ import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart"
-
-
type ChartTimeData = {
time: number
data: {
@@ -1010,6 +1008,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (