mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-16 18:26:16 +01:00
add basic systemd service monitoring (#1153)
Co-authored-by: Shelby Tucker <shelby.tucker@gmail.com>
This commit is contained in:
@@ -43,6 +43,7 @@ type Agent struct {
|
|||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
smartManager *SmartManager // Manages SMART data
|
||||||
|
systemdManager *systemdManager // Manages systemd services
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// 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
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
|
agent.systemdManager, err = newSystemdManager()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Systemd", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
agent.smartManager, err = NewSmartManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("SMART", "err", err)
|
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)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -276,6 +277,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|||||||
response.String = &v
|
response.String = &v
|
||||||
case map[string]smart.SmartData:
|
case map[string]smart.SmartData:
|
||||||
response.SmartData = v
|
response.SmartData = v
|
||||||
|
case systemd.ServiceDetails:
|
||||||
|
response.ServiceInfo = v
|
||||||
// case []byte:
|
// case []byte:
|
||||||
// response.RawBytes = v
|
// response.RawBytes = v
|
||||||
// case string:
|
// case string:
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -174,3 +175,31 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
data := hctx.Agent.smartManager.GetCurrentData()
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -173,6 +174,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
response.String = &v
|
response.String = &v
|
||||||
case map[string]smart.SmartData:
|
case map[string]smart.SmartData:
|
||||||
response.SmartData = v
|
response.SmartData = v
|
||||||
|
case systemd.ServiceDetails:
|
||||||
|
response.ServiceInfo = v
|
||||||
default:
|
default:
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
}
|
}
|
||||||
|
|||||||
229
agent/systemd.go
Normal file
229
agent/systemd.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
28
agent/systemd_nonlinux.go
Normal file
28
agent/systemd_nonlinux.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
53
agent/systemd_nonlinux_test.go
Normal file
53
agent/systemd_nonlinux_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
48
agent/systemd_test.go
Normal file
48
agent/systemd_test.go
Normal file
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// 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 is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -4,6 +4,7 @@ go 1.25.3
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
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/distatus/battery v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
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-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.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/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/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
|
|||||||
4
go.sum
4
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/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 h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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-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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package common
|
|||||||
import (
|
import (
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSocketAction = uint8
|
type WebSocketAction = uint8
|
||||||
@@ -18,6 +19,8 @@ const (
|
|||||||
GetContainerInfo
|
GetContainerInfo
|
||||||
// Request SMART data from agent
|
// Request SMART data from agent
|
||||||
GetSmartData
|
GetSmartData
|
||||||
|
// Request detailed systemd service info from agent
|
||||||
|
GetSystemdInfo
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ type AgentResponse struct {
|
|||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,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"`
|
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
@@ -65,3 +69,7 @@ type ContainerLogsRequest struct {
|
|||||||
type ContainerInfoRequest struct {
|
type ContainerInfoRequest struct {
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemdInfoRequest struct {
|
||||||
|
ServiceName string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -149,7 +150,8 @@ type Info struct {
|
|||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
|
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
127
internal/entities/systemd/systemd.go
Normal file
127
internal/entities/systemd/systemd.go
Normal file
@@ -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
|
||||||
113
internal/entities/systemd/systemd_test.go
Normal file
113
internal/entities/systemd/systemd_test.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -270,6 +270,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// get SMART data
|
// get SMART data
|
||||||
apiAuth.GET("/smart", h.getSmartData)
|
apiAuth.GET("/smart", h.getSmartData)
|
||||||
|
// get systemd service details
|
||||||
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
// get container logs
|
// get container logs
|
||||||
@@ -342,6 +344,27 @@ func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
|
|||||||
}, "info")
|
}, "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
|
// getSmartData handles GET /api/beszel/smart requests
|
||||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
@@ -171,6 +173,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return err
|
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)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
@@ -184,11 +194,50 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return systemRecord, err
|
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
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// shared params for all records
|
||||||
params := dbx.Params{
|
params := dbx.Params{
|
||||||
"system": systemId,
|
"system": systemId,
|
||||||
"updated": time.Now().UTC().UnixMilli(),
|
"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")
|
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
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
||||||
// fetch via websocket
|
// fetch via websocket
|
||||||
|
|||||||
75
internal/hub/systems/system_systemd_test.go
Normal file
75
internal/hub/systems/system_systemd_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"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.
|
// RequestSmartData requests SMART data via WebSocket.
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
||||||
if !ws.IsConnected() {
|
if !ws.IsConnected() {
|
||||||
|
|||||||
75
internal/hub/ws/handlers_test.go
Normal file
75
internal/hub/ws/handlers_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
@@ -1007,6 +1007,148 @@ func init() {
|
|||||||
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
],
|
],
|
||||||
"system": false
|
"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
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -490,6 +490,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = deleteOldSystemdServiceRecords(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -559,6 +563,20 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
return nil
|
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
|
// Deletes container records that haven't been updated in the last 10 minutes
|
||||||
func deleteOldContainerRecords(app core.App) error {
|
func deleteOldContainerRecords(app core.App) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|||||||
@@ -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
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
func TestRecordManagerCreation(t *testing.T) {
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default memo(function ContainerChart({
|
|||||||
// tick formatter
|
// tick formatter
|
||||||
if (chartType === ChartType.CPU) {
|
if (chartType === ChartType.CPU) {
|
||||||
obj.tickFormatter = (value) => {
|
obj.tickFormatter = (value) => {
|
||||||
const val = toFixedFloat(value, 2) + unit
|
const val = `${toFixedFloat(value, 2)}%`
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +78,7 @@ export default memo(function ContainerChart({
|
|||||||
return `${decimalString(value)} ${unit}`
|
return `${decimalString(value)} ${unit}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
|
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
|
||||||
}
|
}
|
||||||
// data function
|
// data function
|
||||||
if (isNetChart) {
|
if (isNetChart) {
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ import NetworkSheet from "./system/network-sheet"
|
|||||||
import CpuCoresSheet from "./system/cpu-sheet"
|
import CpuCoresSheet from "./system/cpu-sheet"
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
data: {
|
data: {
|
||||||
@@ -1010,6 +1008,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
||||||
<LazyContainersTable systemId={id} />
|
<LazyContainersTable systemId={id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||||
|
<LazySystemdTable systemId={id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if lots of sensors */}
|
{/* add space for tooltip if lots of sensors */}
|
||||||
@@ -1179,4 +1181,15 @@ function LazySmartTable({ systemId }: { systemId: string }) {
|
|||||||
{isIntersecting && <SmartTable systemId={systemId} />}
|
{isIntersecting && <SmartTable systemId={systemId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemdTable = lazy(() => import("../systemd-table/systemd-table"))
|
||||||
|
|
||||||
|
function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||||
|
const { isIntersecting, ref } = useIntersectionObserver()
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
|
{isIntersecting && <SystemdTable systemId={systemId} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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<SystemdRecord>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
|
accessorFn: (record) => record.name,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TerminalSquareIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return <span className="ms-1.5 xl:w-50 block truncate">{getValue() as string}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// 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 }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
|
// cell: ({ getValue }) => {
|
||||||
|
// const allSystems = useStore($allSystemsById)
|
||||||
|
// return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
accessorFn: (record) => record.state,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`State`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const statusValue = getValue() as ServiceStatus
|
||||||
|
const statusLabel = ServiceStatusLabels[statusValue] || "Unknown"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="dark:border-white/12">
|
||||||
|
<span className={cn("size-2 me-1.5 rounded-full", getStatusColor(statusValue))} />
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub",
|
||||||
|
accessorFn: (record) => record.sub,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Sub State`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const subState = getValue() as ServiceSubState
|
||||||
|
const subStateLabel = ServiceSubStateLabels[subState] || "Unknown"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="dark:border-white/12 text-xs capitalize">
|
||||||
|
<span className={cn("size-2 me-1.5 rounded-full", getSubStateColor(subState))} />
|
||||||
|
{subStateLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpu",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
if (record.sub !== ServiceSubState.Running) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return record.cpu
|
||||||
|
},
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={`${t`CPU`} (10m)`} Icon={CpuIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (val < 0) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpuPeak",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
if (record.sub !== ServiceSubState.Running) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return record.cpuPeak ?? 0
|
||||||
|
},
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`CPU Peak`} Icon={CpuIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (val < 0) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
accessorFn: (record) => record.memory,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
const formatted = formatBytes(val, false, undefined, false)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memPeak",
|
||||||
|
accessorFn: (record) => record.memPeak,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Memory Peak`} Icon={MemoryStickIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
const formatted = formatBytes(val, false, undefined, false)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
invertSorting: true,
|
||||||
|
accessorFn: (record) => record.updated,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const timestamp = getValue() as number
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">
|
||||||
|
{hourWithSeconds(new Date(timestamp).toISOString())}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function HeaderButton({ column, name, Icon }: { column: Column<SystemdRecord>; name: string; Icon: React.ElementType }) {
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{name}
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
665
internal/site/src/components/systemd-table/systemd-table.tsx
Normal file
665
internal/site/src/components/systemd-table/systemd-table.tsx
Normal file
@@ -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<SystemdRecord[]>([])
|
||||||
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
|
`sort-sd-${systemId ? 1 : 0}`,
|
||||||
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
|
sessionStorage
|
||||||
|
)
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
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<SystemdRecord>("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 (
|
||||||
|
<Card className="p-6 @container w-full">
|
||||||
|
<CardHeader className="p-0 mb-4">
|
||||||
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle className="mb-2">
|
||||||
|
<Trans>Systemd Services</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center">
|
||||||
|
<Trans>Total: {data.length}</Trans>
|
||||||
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
|
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||||
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
|
<Trans>Updated every 10 minutes.</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="rounded-md">
|
||||||
|
<AllSystemdTable table={table} rows={rows} colLength={visibleColumns.length} systemId={systemId} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AllSystemdTable = memo(function AllSystemdTable({
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
colLength,
|
||||||
|
systemId,
|
||||||
|
}: {
|
||||||
|
table: TableType<SystemdRecord>
|
||||||
|
rows: Row<SystemdRecord>[]
|
||||||
|
colLength: number
|
||||||
|
systemId?: string
|
||||||
|
}) {
|
||||||
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeService = useRef<SystemdRecord | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const openSheet = (service: SystemdRecord) => {
|
||||||
|
activeService.current = service
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
{/* add header height to table size */}
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="text-sm w-full h-full text-nowrap">
|
||||||
|
<SystemdTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return <SystemdTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<SystemdSheet
|
||||||
|
sheetOpen={sheetOpen}
|
||||||
|
setSheetOpen={setSheetOpen}
|
||||||
|
activeService={activeService}
|
||||||
|
systemId={systemId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function SystemdSheet({
|
||||||
|
sheetOpen,
|
||||||
|
setSheetOpen,
|
||||||
|
activeService,
|
||||||
|
systemId,
|
||||||
|
}: {
|
||||||
|
sheetOpen: boolean
|
||||||
|
setSheetOpen: (open: boolean) => void
|
||||||
|
activeService: React.RefObject<SystemdRecord | null>
|
||||||
|
systemId?: string
|
||||||
|
}) {
|
||||||
|
const service = activeService.current
|
||||||
|
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(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 = <span className="text-muted-foreground">N/A</span>
|
||||||
|
|
||||||
|
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 && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">
|
||||||
|
{t`(limit: ${max})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{max === null && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">
|
||||||
|
{t`(limit: unlimited)`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor(service.state))} />
|
||||||
|
{stateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr key={key} className="border-b last:border-b-0">
|
||||||
|
<td className="px-3 py-2 font-medium bg-muted dark:bg-muted/40 align-top w-35">{label}</td>
|
||||||
|
<td className="px-3 py-2">{value ?? notAvailable}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
||||||
|
<SheetHeader className="p-0">
|
||||||
|
<SheetTitle>
|
||||||
|
<Trans>Service Details</Trans>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||||
|
<Trans>Loading...</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Error</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{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`,
|
||||||
|
<div className="flex items-center">
|
||||||
|
{details?.UnitFileState}
|
||||||
|
{details?.UnitFilePreset && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">(preset: {details?.UnitFilePreset})</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
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
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Runtime Metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{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)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Relationships</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{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
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Lifecycle</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{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)} */}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Capabilities</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{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`)} */}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemdTableRow = memo(function SystemdTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
openSheet,
|
||||||
|
}: {
|
||||||
|
row: Row<SystemdRecord>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
openSheet: (service: SystemdRecord) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer transition-opacity"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="py-0"
|
||||||
|
style={{
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -71,3 +71,26 @@ export enum ConnectionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const connectionTypeLabels = ["", "SSH", "WebSocket"] as const
|
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
|
||||||
|
|||||||
137
internal/site/src/types.d.ts
vendored
137
internal/site/src/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
|||||||
import type { RecordModel } from "pocketbase"
|
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
|
// global window properties
|
||||||
declare global {
|
declare global {
|
||||||
@@ -84,10 +84,10 @@ export interface SystemStats {
|
|||||||
cpu: number
|
cpu: number
|
||||||
/** peak cpu */
|
/** peak cpu */
|
||||||
cpum?: number
|
cpum?: number
|
||||||
/** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */
|
/** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */
|
||||||
cpub?: number[]
|
cpub?: number[]
|
||||||
/** per-core cpu usage [CPU0..] (0-100 integers) */
|
/** per-core cpu usage [CPU0..] (0-100 integers) */
|
||||||
cpus?: number[]
|
cpus?: number[]
|
||||||
// TODO: remove these in future release in favor of la
|
// TODO: remove these in future release in favor of la
|
||||||
/** load average 1 minute */
|
/** load average 1 minute */
|
||||||
l1?: number
|
l1?: number
|
||||||
@@ -356,4 +356,131 @@ export interface SmartAttribute {
|
|||||||
rs?: string
|
rs?: string
|
||||||
/** when failed */
|
/** when failed */
|
||||||
wf?: string
|
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<number | string>;
|
||||||
|
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[];
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user