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 && ( )} + + {system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && ( + + )} {/* add space for tooltip if lots of sensors */} @@ -1179,4 +1181,15 @@ function LazySmartTable({ systemId }: { systemId: string }) { {isIntersecting && } ) +} + +const SystemdTable = lazy(() => import("../systemd-table/systemd-table")) + +function LazySystemdTable({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver() + return ( +
+ {isIntersecting && } +
+ ) } \ No newline at end of file diff --git a/internal/site/src/components/systemd-table/systemd-table-columns.tsx b/internal/site/src/components/systemd-table/systemd-table-columns.tsx new file mode 100644 index 00000000..d3eb1f86 --- /dev/null +++ b/internal/site/src/components/systemd-table/systemd-table-columns.tsx @@ -0,0 +1,200 @@ +import type { Column, ColumnDef } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils" +import type { SystemdRecord } from "@/types" +import { ServiceStatus, ServiceStatusLabels, ServiceSubState, ServiceSubStateLabels } from "@/lib/enums" +import { + ActivityIcon, + ArrowUpDownIcon, + ClockIcon, + CpuIcon, + MemoryStickIcon, + TerminalSquareIcon, +} from "lucide-react" +import { Badge } from "../ui/badge" +import { t } from "@lingui/core/macro" +// import { $allSystemsById } from "@/lib/stores" +// import { useStore } from "@nanostores/react" + +function getSubStateColor(subState: ServiceSubState) { + switch (subState) { + case ServiceSubState.Running: + return "bg-green-500" + case ServiceSubState.Failed: + return "bg-red-500" + case ServiceSubState.Dead: + return "bg-yellow-500" + default: + return "bg-zinc-500" + } +} + + +export const systemdTableCols: ColumnDef[] = [ + { + id: "name", + sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), + accessorFn: (record) => record.name, + header: ({ column }) => , + cell: ({ getValue }) => { + return {getValue() as string} + }, + }, + // { + // id: "system", + // accessorFn: (record) => record.system, + // sortingFn: (a, b) => { + // const allSystems = $allSystemsById.get() + // const systemNameA = allSystems[a.original.system]?.name ?? "" + // const systemNameB = allSystems[b.original.system]?.name ?? "" + // return systemNameA.localeCompare(systemNameB) + // }, + // header: ({ column }) => , + // cell: ({ getValue }) => { + // const allSystems = useStore($allSystemsById) + // return {allSystems[getValue() as string]?.name ?? ""} + // }, + // }, + { + id: "state", + accessorFn: (record) => record.state, + header: ({ column }) => , + cell: ({ getValue }) => { + const statusValue = getValue() as ServiceStatus + const statusLabel = ServiceStatusLabels[statusValue] || "Unknown" + return ( + + + {statusLabel} + + ) + }, + }, + { + id: "sub", + accessorFn: (record) => record.sub, + header: ({ column }) => , + cell: ({ getValue }) => { + const subState = getValue() as ServiceSubState + const subStateLabel = ServiceSubStateLabels[subState] || "Unknown" + return ( + + + {subStateLabel} + + ) + }, + }, + { + id: "cpu", + accessorFn: (record) => { + if (record.sub !== ServiceSubState.Running) { + return -1 + } + return record.cpu + }, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + if (val < 0) { + return N/A + } + return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} + }, + }, + { + id: "cpuPeak", + accessorFn: (record) => { + if (record.sub !== ServiceSubState.Running) { + return -1 + } + return record.cpuPeak ?? 0 + }, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + if (val < 0) { + return N/A + } + return {`${decimalString(val, val >= 10 ? 1 : 2)}%`} + }, + }, + { + id: "memory", + accessorFn: (record) => record.memory, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + if (!val) { + return N/A + } + const formatted = formatBytes(val, false, undefined, false) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "memPeak", + accessorFn: (record) => record.memPeak, + invertSorting: true, + header: ({ column }) => , + cell: ({ getValue }) => { + const val = getValue() as number + if (!val) { + return N/A + } + const formatted = formatBytes(val, false, undefined, false) + return ( + {`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`} + ) + }, + }, + { + id: "updated", + invertSorting: true, + accessorFn: (record) => record.updated, + header: ({ column }) => , + cell: ({ getValue }) => { + const timestamp = getValue() as number + return ( + + {hourWithSeconds(new Date(timestamp).toISOString())} + + ) + }, + }, +] + +function HeaderButton({ column, name, Icon }: { column: Column; name: string; Icon: React.ElementType }) { + const isSorted = column.getIsSorted() + return ( + + ) +} + +export function getStatusColor(status: ServiceStatus) { + switch (status) { + case ServiceStatus.Active: + return "bg-green-500" + case ServiceStatus.Failed: + return "bg-red-500" + case ServiceStatus.Reloading: + case ServiceStatus.Activating: + case ServiceStatus.Deactivating: + return "bg-yellow-500" + default: + return "bg-zinc-500" + } +} \ No newline at end of file diff --git a/internal/site/src/components/systemd-table/systemd-table.tsx b/internal/site/src/components/systemd-table/systemd-table.tsx new file mode 100644 index 00000000..8efa7289 --- /dev/null +++ b/internal/site/src/components/systemd-table/systemd-table.tsx @@ -0,0 +1,665 @@ +import { t } from "@lingui/core/macro" +import { Trans } from "@lingui/react/macro" +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + type Row, + type SortingState, + type Table as TableType, + useReactTable, + type VisibilityState, +} from "@tanstack/react-table" +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" +import { LoaderCircleIcon } from "lucide-react" +import { listenKeys } from "nanostores" +import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react" +import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" +import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { pb } from "@/lib/api" +import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums" +import { $allSystemsById } from "@/lib/stores" +import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils" +import type { SystemdRecord, SystemdServiceDetails } from "@/types" +import { Separator } from "../ui/separator" + +export default function SystemdTable({ systemId }: { systemId?: string }) { + const loadTime = Date.now() + const [data, setData] = useState([]) + const [sorting, setSorting] = useBrowserStorage( + `sort-sd-${systemId ? 1 : 0}`, + [{ id: systemId ? "name" : "system", desc: false }], + sessionStorage + ) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [globalFilter, setGlobalFilter] = useState("") + + // clear old data when systemId changes + useEffect(() => { + return setData([]) + }, [systemId]) + + + useEffect(() => { + const lastUpdated = data[0]?.updated ?? 0 + + function fetchData(systemId?: string) { + pb.collection("systemd_services") + .getList(0, 2000, { + fields: "name,state,sub,cpu,cpuPeak,memory,memPeak,updated", + filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined, + }) + .then( + ({ items }) => + items.length && + setData((curItems) => { + const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0) + const systemdNames = new Set() + const newItems: SystemdRecord[] = [] + for (const item of items) { + if (Math.abs(lastUpdated - item.updated) < 70_000) { + systemdNames.add(item.name) + newItems.push(item) + } + } + for (const item of curItems) { + if (!systemdNames.has(item.name) && lastUpdated - item.updated < 70_000) { + newItems.push(item) + } + } + return newItems + }) + ) + } + + // initial load + fetchData(systemId) + + // if no systemId, pull system containers after every system update + if (!systemId) { + return $allSystemsById.listen((_value, _oldValue, systemId) => { + // exclude initial load of systems + if (Date.now() - loadTime > 500) { + fetchData(systemId) + } + }) + } + + // if systemId, fetch containers after the system is updated + return listenKeys($allSystemsById, [systemId], (_newSystems) => { + // don't fetch data if the last update is less than 9.5 minutes + if (lastUpdated > Date.now() - 9.5 * 60 * 1000) { + return + } + fetchData(systemId) + }) + }, [systemId]) + + const table = useReactTable({ + data, + // columns: systemdTableCols.filter((col) => (systemId ? col.id !== "system" : true)), + columns: systemdTableCols, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + defaultColumn: { + sortUndefined: "last", + size: 100, + minSize: 0, + }, + state: { + sorting, + columnFilters, + columnVisibility, + globalFilter, + }, + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: (row, _columnId, filterValue) => { + const service = row.original + const systemName = $allSystemsById.get()[service.system]?.name ?? "" + const name = service.name ?? "" + const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? "" + const subState = service.sub ?? "" + const searchString = `${systemName} ${name} ${statusLabel} ${subState}`.toLowerCase() + + return (filterValue as string) + .toLowerCase() + .split(" ") + .every((term) => searchString.includes(term)) + }, + }) + + const rows = table.getRowModel().rows + const visibleColumns = table.getVisibleLeafColumns() + + const statusTotals = useMemo(() => { + const totals = [0, 0, 0, 0, 0, 0] + for (const service of data) { + totals[service.state]++ + } + return totals + }, [data]) + + if (!data.length && !globalFilter) { + return null + } + + return ( + + +
+
+ + Systemd Services + + + Total: {data.length} + + Failed: {statusTotals[ServiceStatus.Failed]} + + Updated every 10 minutes. + +
+ setGlobalFilter(e.target.value)} + className="ms-auto px-4 w-full max-w-full md:w-64" + /> +
+
+
+ +
+
+ ) +} + +const AllSystemdTable = memo(function AllSystemdTable({ + table, + rows, + colLength, + systemId, +}: { + table: TableType + rows: Row[] + colLength: number + systemId?: string +}) { + // The virtualizer will need a reference to the scrollable container element + const scrollRef = useRef(null) + const activeService = useRef(null) + const [sheetOpen, setSheetOpen] = useState(false) + const openSheet = (service: SystemdRecord) => { + activeService.current = service + setSheetOpen(true) + } + + const virtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => 54, + getScrollElement: () => scrollRef.current, + overscan: 5, + }) + const virtualRows = virtualizer.getVirtualItems() + + const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin) + const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)) + + return ( +
2) && "min-h-50" + )} + ref={scrollRef} + > + {/* add header height to table size */} +
+ + + + {rows.length ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + return + }) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ) +}) + +function SystemdSheet({ + sheetOpen, + setSheetOpen, + activeService, + systemId, +}: { + sheetOpen: boolean + setSheetOpen: (open: boolean) => void + activeService: React.RefObject + systemId?: string +}) { + const service = activeService.current + const [details, setDetails] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!sheetOpen || !service) { + return + } + + setError(null) + + let cancelled = false + setDetails(null) + setIsLoading(true) + + pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", { + query: { + system: systemId, + service: service.name, + }, + }) + .then(({ details }) => { + if (cancelled) return + if (details) { + setDetails(details) + } else { + setDetails(null) + setError(t`No systemd details returned`) + } + }) + .catch((err) => { + if (cancelled) return + setError(err?.message ?? "Failed to load service details") + setDetails(null) + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [sheetOpen, service, systemId]) + + if (!service) return null + + const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? "" + const subStateLabel = ServiceSubStateLabels[service.sub as ServiceSubState] ?? "" + + const notAvailable = N/A + + const formatMemory = (value?: number | null) => { + if (value === undefined || value === null) { + return value === null ? t`Unlimited` : undefined + } + const { value: convertedValue, unit } = formatBytes(value, false, undefined, false) + const digits = convertedValue >= 10 ? 1 : 2 + return `${decimalString(convertedValue, digits)} ${unit}` + } + + const formatCpuTime = (ns?: number) => { + if (!ns) return undefined + const seconds = ns / 1_000_000_000 + if (seconds >= 3600) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = Math.floor(seconds % 60) + return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null] + .filter(Boolean) + .join(" ") + } + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${minutes}m ${secs}s` + } + if (seconds >= 1) { + return `${decimalString(seconds, 2)}s` + } + return `${decimalString(seconds * 1000, 2)}ms` + } + + const formatTasks = (current?: number, max?: number) => { + const hasCurrent = typeof current === "number" && current >= 0 + const hasMax = typeof max === "number" && max > 0 && max !== null + if (!hasCurrent && !hasMax) { + return undefined + } + return ( + <> + {hasCurrent ? current : notAvailable} + {hasMax && ( + + {t`(limit: ${max})`} + + )} + {max === null && ( + + {t`(limit: unlimited)`} + + )} + + ) + } + + const formatTimestamp = (timestamp?: number) => { + if (!timestamp) return undefined + // systemd timestamps are in microseconds, convert to milliseconds for JavaScript Date + const date = new Date(timestamp / 1000) + if (Number.isNaN(date.getTime())) return undefined + return date.toLocaleString() + } + + const activeStateValue = (() => { + const stateText = details?.ActiveState + ? details.SubState + ? `${details.ActiveState} (${details.SubState})` + : details.ActiveState + : subStateLabel + ? `${statusLabel} (${subStateLabel})` + : statusLabel + + for (const [index, status] of ServiceStatusLabels.entries()) { + if (details?.ActiveState?.toLowerCase() === status.toLowerCase()) { + service.state = index as ServiceStatus + break + } + } + + return ( +
+
+ {stateText} +
+ ) + })() + + const statusTextValue = details?.Result + + const cpuTime = formatCpuTime(details?.CPUUsageNSec) + const tasks = formatTasks(details?.TasksCurrent, details?.TasksMax) + const memoryCurrent = formatMemory(details?.MemoryCurrent) + const memoryPeak = formatMemory(details?.MemoryPeak) + const memoryLimit = formatMemory(details?.MemoryLimit) + const restartsValue = typeof details?.NRestarts === "number" ? details.NRestarts : undefined + const mainPidValue = typeof details?.MainPID === "number" && details.MainPID > 0 ? details.MainPID : undefined + const execMainPidValue = + typeof details?.ExecMainPID === "number" && details.ExecMainPID > 0 && details.ExecMainPID !== details?.MainPID + ? details.ExecMainPID + : undefined + const activeEnterTimestamp = formatTimestamp(details?.ActiveEnterTimestamp) + const activeExitTimestamp = formatTimestamp(details?.ActiveExitTimestamp) + const inactiveEnterTimestamp = formatTimestamp(details?.InactiveEnterTimestamp) + const execMainStartTimestamp = undefined // Property not available in current systemd interface + + const renderRow = (key: string, label: ReactNode, value?: ReactNode, alwaysShow = false) => { + if (!alwaysShow && (value === undefined || value === null || value === "")) { + return null + } + return ( + + {label} + {value ?? notAvailable} + + ) + } + + return ( + + + + + Service Details + + +
+ {isLoading && ( +
+ + Loading... +
+ )} + {error && ( + + + Error + + {error} + + )} + +
+
+ + + {renderRow("name", t`Name`, service.name, true)} + {renderRow("description", t`Description`, details?.Description, true)} + {renderRow("loadState", t`Load State`, details?.LoadState, true)} + {renderRow( + "bootState", + t`Boot State`, +
+ {details?.UnitFileState} + {details?.UnitFilePreset && ( + (preset: {details?.UnitFilePreset}) + )} +
, + true + )} + {renderRow("unitFile", t`Unit File`, details?.FragmentPath, true)} + {renderRow("active", t`Active State`, activeStateValue, true)} + {renderRow("status", t`Status`, statusTextValue, true)} + {renderRow( + "documentation", + t`Documentation`, + Array.isArray(details?.Documentation) && details.Documentation.length > 0 + ? details.Documentation.join(", ") + : undefined + )} + +
+
+
+ +
+

+ Runtime Metrics +

+
+ + + {renderRow("mainPid", t`Main PID`, mainPidValue, true)} + {renderRow("execMainPid", t`Exec Main PID`, execMainPidValue)} + {renderRow("tasks", t`Tasks`, tasks, true)} + {renderRow("cpuTime", t`CPU Time`, cpuTime)} + {renderRow("memory", t`Memory`, memoryCurrent, true)} + {renderRow("memoryPeak", t`Memory Peak`, memoryPeak)} + {renderRow("memoryLimit", t`Memory Limit`, memoryLimit)} + {renderRow("restarts", t`Restarts`, restartsValue, true)} + +
+
+
+ +
+

+ Relationships +

+
+ + + {renderRow( + "wants", + t`Wants`, + Array.isArray(details?.Wants) && details.Wants.length > 0 ? details.Wants.join(", ") : undefined + )} + {renderRow( + "requires", + t`Requires`, + Array.isArray(details?.Requires) && details.Requires.length > 0 + ? details.Requires.join(", ") + : undefined + )} + {renderRow( + "requiredBy", + t`Required By`, + Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0 + ? details.RequiredBy.join(", ") + : undefined + )} + {renderRow( + "conflicts", + t`Conflicts`, + Array.isArray(details?.Conflicts) && details.Conflicts.length > 0 + ? details.Conflicts.join(", ") + : undefined + )} + {renderRow( + "before", + t`Before`, + Array.isArray(details?.Before) && details.Before.length > 0 ? details.Before.join(", ") : undefined + )} + {renderRow( + "after", + t`After`, + Array.isArray(details?.After) && details.After.length > 0 ? details.After.join(", ") : undefined + )} + {renderRow( + "triggers", + t`Triggers`, + Array.isArray(details?.Triggers) && details.Triggers.length > 0 + ? details.Triggers.join(", ") + : undefined + )} + {renderRow( + "triggeredBy", + t`Triggered By`, + Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0 + ? details.TriggeredBy.join(", ") + : undefined + )} + +
+
+
+ +
+

+ Lifecycle +

+
+ + + {renderRow("activeSince", t`Became Active`, activeEnterTimestamp)} + {service.state !== ServiceStatus.Active && + renderRow("lastActive", t`Exited Active`, activeExitTimestamp)} + {renderRow("inactiveSince", t`Became Inactive`, inactiveEnterTimestamp)} + {renderRow("execMainStart", t`Process Started`, execMainStartTimestamp)} + {/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */} + {/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */} + +
+
+
+ +
+

+ Capabilities +

+
+ + + {renderRow("canStart", t`Can Start`, details?.CanStart ? t`Yes` : t`No`)} + {renderRow("canStop", t`Can Stop`, details?.CanStop ? t`Yes` : t`No`)} + {renderRow("canReload", t`Can Reload`, details?.CanReload ? t`Yes` : t`No`)} + {/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? t`Yes` : t`No`)} + {renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */} + +
+
+
+
+
+
+ ) +} + +function SystemdTableHead({ table }: { table: TableType }) { + return ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + ) +} + +const SystemdTableRow = memo(function SystemdTableRow({ + row, + virtualRow, + openSheet, +}: { + row: Row + virtualRow: VirtualItem + openSheet: (service: SystemdRecord) => void +}) { + return ( + openSheet(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) +}) diff --git a/internal/site/src/lib/enums.ts b/internal/site/src/lib/enums.ts index 4145086a..a725763f 100644 --- a/internal/site/src/lib/enums.ts +++ b/internal/site/src/lib/enums.ts @@ -71,3 +71,26 @@ export enum ConnectionType { } export const connectionTypeLabels = ["", "SSH", "WebSocket"] as const + +/** Systemd service state */ +export enum ServiceStatus { + Active, + Inactive, + Failed, + Activating, + Deactivating, + Reloading, +} + +export const ServiceStatusLabels = ["Active", "Inactive", "Failed", "Activating", "Deactivating", "Reloading"] as const + +/** Systemd service sub state */ +export enum ServiceSubState { + Dead, + Running, + Exited, + Failed, + Unknown, +} + +export const ServiceSubStateLabels = ["Dead", "Running", "Exited", "Failed", "Unknown"] as const diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index c54849a2..ddeb9b36 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -1,5 +1,5 @@ import type { RecordModel } from "pocketbase" -import type { Unit, Os, BatteryState, HourFormat, ConnectionType } from "@/lib/enums" +import type { Unit, Os, BatteryState, HourFormat, ConnectionType, ServiceStatus, ServiceSubState } from "@/lib/enums" // global window properties declare global { @@ -84,10 +84,10 @@ export interface SystemStats { cpu: number /** peak cpu */ cpum?: number - /** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */ - cpub?: number[] - /** per-core cpu usage [CPU0..] (0-100 integers) */ - cpus?: number[] + /** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */ + cpub?: number[] + /** per-core cpu usage [CPU0..] (0-100 integers) */ + cpus?: number[] // TODO: remove these in future release in favor of la /** load average 1 minute */ l1?: number @@ -356,4 +356,131 @@ export interface SmartAttribute { rs?: string /** when failed */ wf?: string +} + +export interface SystemdRecord extends RecordModel { + system: string + name: string + state: ServiceStatus + sub: ServiceSubState + cpu: number + cpuPeak: number + memory: number + memPeak: number + updated: number +} + +export interface SystemdServiceDetails { + AccessSELinuxContext: string; + ActivationDetails: any[]; + ActiveEnterTimestamp: number; + ActiveEnterTimestampMonotonic: number; + ActiveExitTimestamp: number; + ActiveExitTimestampMonotonic: number; + ActiveState: string; + After: string[]; + AllowIsolate: boolean; + AssertResult: boolean; + AssertTimestamp: number; + AssertTimestampMonotonic: number; + Asserts: any[]; + Before: string[]; + BindsTo: any[]; + BoundBy: any[]; + CPUUsageNSec: number; + CanClean: any[]; + CanFreeze: boolean; + CanIsolate: boolean; + CanLiveMount: boolean; + CanReload: boolean; + CanStart: boolean; + CanStop: boolean; + CollectMode: string; + ConditionResult: boolean; + ConditionTimestamp: number; + ConditionTimestampMonotonic: number; + Conditions: any[]; + ConflictedBy: any[]; + Conflicts: string[]; + ConsistsOf: any[]; + DebugInvocation: boolean; + DefaultDependencies: boolean; + Description: string; + Documentation: string[]; + DropInPaths: any[]; + ExecMainPID: number; + FailureAction: string; + FailureActionExitStatus: number; + Following: string; + FragmentPath: string; + FreezerState: string; + Id: string; + IgnoreOnIsolate: boolean; + InactiveEnterTimestamp: number; + InactiveEnterTimestampMonotonic: number; + InactiveExitTimestamp: number; + InactiveExitTimestampMonotonic: number; + InvocationID: string; + Job: Array; + JobRunningTimeoutUSec: number; + JobTimeoutAction: string; + JobTimeoutRebootArgument: string; + JobTimeoutUSec: number; + JoinsNamespaceOf: any[]; + LoadError: string[]; + LoadState: string; + MainPID: number; + Markers: any[]; + MemoryCurrent: number; + MemoryLimit: number; + MemoryPeak: number; + NRestarts: number; + Names: string[]; + NeedDaemonReload: boolean; + OnFailure: any[]; + OnFailureJobMode: string; + OnFailureOf: any[]; + OnSuccess: any[]; + OnSuccessJobMode: string; + OnSuccessOf: any[]; + PartOf: any[]; + Perpetual: boolean; + PropagatesReloadTo: any[]; + PropagatesStopTo: any[]; + RebootArgument: string; + Refs: any[]; + RefuseManualStart: boolean; + RefuseManualStop: boolean; + ReloadPropagatedFrom: any[]; + RequiredBy: any[]; + Requires: string[]; + RequiresMountsFor: any[]; + Requisite: any[]; + RequisiteOf: any[]; + Result: string; + SliceOf: any[]; + SourcePath: string; + StartLimitAction: string; + StartLimitBurst: number; + StartLimitIntervalUSec: number; + StateChangeTimestamp: number; + StateChangeTimestampMonotonic: number; + StopPropagatedFrom: any[]; + StopWhenUnneeded: boolean; + SubState: string; + SuccessAction: string; + SuccessActionExitStatus: number; + SurviveFinalKillSignal: boolean; + TasksCurrent: number; + TasksMax: number; + Transient: boolean; + TriggeredBy: string[]; + Triggers: any[]; + UnitFilePreset: string; + UnitFileState: string; + UpheldBy: any[]; + Upholds: any[]; + WantedBy: any[]; + Wants: string[]; + WantsMountsFor: any[]; } \ No newline at end of file