From 40b3951615798a0ff240f0e72a077710f7331da1 Mon Sep 17 00:00:00 2001 From: Shelby Tucker Date: Mon, 10 Nov 2025 15:29:21 -0500 Subject: [PATCH] [Feature] Basic systemd service monitoring (#1153) * basic systemd service monitoring * update to work after /internal rename * monitor systemd service cpu and memory usage --------- Co-authored-by: henrygd --- agent/agent.go | 13 ++ agent/systemd.go | 107 ++++++++++ agent/systemd_unsupported.go | 18 ++ go.mod | 2 + go.sum | 4 + internal/entities/system/system.go | 8 +- internal/entities/systemd/systemd.go | 34 +++ internal/hub/systems/system.go | 14 ++ .../0_collections_snapshot_0_12_0_7.go | 90 ++++++++ internal/site/package-lock.json | 156 +++++++------- .../charts/systemd-services-table.tsx | 195 ++++++++++++++++++ .../site/src/components/routes/system.tsx | 39 +++- .../systems-table/systems-table-columns.tsx | 53 ++++- internal/site/src/locales/en/en.po | 36 ++++ internal/site/src/locales/is/is.po | 36 ++++ internal/site/src/types.d.ts | 17 ++ 16 files changed, 734 insertions(+), 88 deletions(-) create mode 100644 agent/systemd.go create mode 100644 agent/systemd_unsupported.go create mode 100644 internal/entities/systemd/systemd.go create mode 100644 internal/site/src/components/charts/systemd-services-table.tsx diff --git a/agent/agent.go b/agent/agent.go index 6c6a9072..d249203f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -31,6 +31,7 @@ type Agent struct { netInterfaces map[string]struct{} // Stores all valid network interfaces netIoStats system.NetIoStats // Keeps track of bandwidth usage dockerManager *dockerManager // Manages Docker API requests + systemdManager *systemdManager // Manages systemd services sensorConfig *SensorConfig // Sensors config systemInfo system.Info // Host system info gpuManager *GPUManager // Manages GPU data @@ -88,6 +89,13 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) { // initialize docker manager agent.dockerManager = newDockerManager(agent) + // initialize systemd manager + if sm, err := newSystemdManager(); err != nil { + slog.Debug("Systemd", "err", err) + } else { + agent.systemdManager = sm + } + // initialize GPU manager if gm, err := NewGPUManager(); err != nil { slog.Debug("GPU", "err", err) @@ -137,6 +145,11 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData { } } + if a.systemdManager != nil { + data.SystemdServices = a.systemdManager.getServiceStats() + slog.Debug("Systemd services", "data", data.SystemdServices) + } + data.Stats.ExtraFs = make(map[string]*system.FsStats) for name, stats := range a.fsStats { if !stats.Root && stats.DiskTotal > 0 { diff --git a/agent/systemd.go b/agent/systemd.go new file mode 100644 index 00000000..3010b477 --- /dev/null +++ b/agent/systemd.go @@ -0,0 +1,107 @@ +//go:build linux + +package agent + +import ( + "context" + "log/slog" + "math" + "strings" + "sync" + + "github.com/coreos/go-systemd/v22/dbus" + "github.com/henrygd/beszel/internal/entities/systemd" +) + +// systemdManager manages the collection of systemd service statistics. +type systemdManager struct { + conn *dbus.Conn + serviceStatsMap map[string]*systemd.Service + mu sync.Mutex +} + +// newSystemdManager creates a new systemdManager. +func newSystemdManager() (*systemdManager, error) { + conn, err := dbus.New() + if err != nil { + if strings.Contains(err.Error(), "permission denied") { + slog.Error("Permission denied when connecting to systemd. Run as root or with appropriate user permissions.", "err", err) + return nil, err + } + slog.Error("Error connecting to systemd", "err", err) + return nil, err + } + + return &systemdManager{ + conn: conn, + serviceStatsMap: make(map[string]*systemd.Service), + }, nil +} + +// getServiceStats collects statistics for all running systemd services. +func (sm *systemdManager) getServiceStats() []*systemd.Service { + units, err := sm.conn.ListUnitsContext(context.Background()) + if err != nil { + slog.Error("Error listing systemd units", "err", err) + return nil + } + + var services []*systemd.Service + for _, unit := range units { + if strings.HasSuffix(unit.Name, ".service") { + service := sm.updateServiceStats(unit) + services = append(services, service) + } + } + return services +} + +// updateServiceStats updates the statistics for a single systemd service. +func (sm *systemdManager) updateServiceStats(unit dbus.UnitStatus) *systemd.Service { + sm.mu.Lock() + defer sm.mu.Unlock() + + props, err := sm.conn.GetUnitTypeProperties(unit.Name, "Service") + if err != nil { + slog.Debug("could not get unit type properties", "unit", unit.Name, "err", err) + return &systemd.Service{ + Name: unit.Name, + Status: unit.ActiveState, + } + } + + var cpuUsage uint64 + if val, ok := props["CPUUsageNSec"]; ok { + if v, ok := val.(uint64); ok { + cpuUsage = v + } + } + + var memUsage uint64 + if val, ok := props["MemoryCurrent"]; ok { + if v, ok := val.(uint64); ok { + memUsage = v + } + } + + service, exists := sm.serviceStatsMap[unit.Name] + if !exists { + service = &systemd.Service{ + Name: unit.Name, + Status: unit.ActiveState, + } + sm.serviceStatsMap[unit.Name] = service + } + + service.Status = unit.ActiveState + + // If memUsage is MaxUint64 the api is saying it's not available, return 0 + if memUsage == math.MaxUint64 { + memUsage = 0 + } + + service.Mem = float64(memUsage) / (1024 * 1024) // Convert to MB + service.CalculateCPUPercent(cpuUsage) + + return service +} diff --git a/agent/systemd_unsupported.go b/agent/systemd_unsupported.go new file mode 100644 index 00000000..0cd72040 --- /dev/null +++ b/agent/systemd_unsupported.go @@ -0,0 +1,18 @@ +//go:build !linux + +package agent + +import "github.com/henrygd/beszel/internal/entities/systemd" + +// systemdManager manages the collection of systemd service statistics. +type systemdManager struct{} + +// newSystemdManager creates a new systemdManager. +func newSystemdManager() (*systemdManager, error) { + return &systemdManager{}, nil +} + +// getServiceStats returns nil for non-linux systems. +func (sm *systemdManager) getServiceStats() []*systemd.Service { + return nil +} diff --git a/go.mod b/go.mod index 7e0966f5..91aa5b59 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr require ( github.com/blang/semver v3.5.1+incompatible + github.com/coreos/go-systemd/v22 v22.6.0 github.com/distatus/battery v0.11.0 github.com/fxamacker/cbor/v2 v2.9.0 github.com/gliderlabs/ssh v0.3.8 @@ -40,6 +41,7 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-sql-driver/mysql v1.9.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/go.sum b/go.sum index 55337bfd..f46d9a88 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= +github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -49,6 +51,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index c04e0404..3c9f7781 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -6,6 +6,7 @@ import ( "time" "github.com/henrygd/beszel/internal/entities/container" + "github.com/henrygd/beszel/internal/entities/systemd" ) type Stats struct { @@ -121,7 +122,8 @@ type Info struct { // Final data structure to return to the hub type CombinedData struct { - Stats Stats `json:"stats" cbor:"0,keyasint"` - Info Info `json:"info" cbor:"1,keyasint"` - Containers []*container.Stats `json:"container" cbor:"2,keyasint"` + Stats Stats `json:"stats" cbor:"0,keyasint"` + Info Info `json:"info" cbor:"1,keyasint"` + Containers []*container.Stats `json:"container" cbor:"2,keyasint"` + SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"` } diff --git a/internal/entities/systemd/systemd.go b/internal/entities/systemd/systemd.go new file mode 100644 index 00000000..f113ea9b --- /dev/null +++ b/internal/entities/systemd/systemd.go @@ -0,0 +1,34 @@ +package systemd + +import ( + "runtime" + "time" +) + +// Service represents a single systemd service with its stats. +type Service struct { + Name string `json:"n" cbor:"0,keyasint"` + Status string `json:"s" cbor:"1,keyasint"` + Cpu float64 `json:"c" cbor:"2,keyasint"` + Mem float64 `json:"m" cbor:"3,keyasint"` + PrevCpuUsage uint64 `json:"-"` + PrevReadTime time.Time `json:"-"` +} + +// CalculateCPUPercent calculates the CPU usage percentage for the service. +func (s *Service) CalculateCPUPercent(cpuUsage uint64) { + if s.PrevReadTime.IsZero() { + s.Cpu = 0 + } else { + duration := time.Since(s.PrevReadTime).Nanoseconds() + if duration > 0 { + coreCount := int64(runtime.NumCPU()) + duration *= coreCount + cpuPercent := float64(cpuUsage-s.PrevCpuUsage) / float64(duration) + s.Cpu = cpuPercent * 100 + } + } + + s.PrevCpuUsage = cpuUsage + s.PrevReadTime = time.Now() +} diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 03c6a557..216b215f 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -161,6 +161,20 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error return nil, err } } + // add new systemd_stats record + if len(data.SystemdServices) > 0 { + systemdStatsCollection, err := hub.FindCachedCollectionByNameOrId("systemd_stats") + if err != nil { + return nil, err + } + systemdStatsRecord := core.NewRecord(systemdStatsCollection) + systemdStatsRecord.Set("system", systemRecord.Id) + systemdStatsRecord.Set("stats", data.SystemdServices) + systemdStatsRecord.Set("type", "1m") + if err := hub.SaveNoValidate(systemdStatsRecord); err != nil { + return nil, err + } + } // update system record (do this last because it triggers alerts and we need above records to be inserted first) systemRecord.Set("status", up) diff --git a/internal/migrations/0_collections_snapshot_0_12_0_7.go b/internal/migrations/0_collections_snapshot_0_12_0_7.go index 01cdbff7..bf881fa1 100644 --- a/internal/migrations/0_collections_snapshot_0_12_0_7.go +++ b/internal/migrations/0_collections_snapshot_0_12_0_7.go @@ -520,6 +520,96 @@ func init() { ], "system": false }, + { + "id": "systemd_stats_collection", + "listRule": "@request.auth.id != \"\"", + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "systemd_stats", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": true, + "collectionId": "2hz5ncl8tizk5nx", + "hidden": false, + "id": "hutcu6ps", + "maxSelect": 1, + "minSelect": 0, + "name": "system", + "presentable": false, + "required": true, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "r39hhnil", + "maxSize": 2000000, + "name": "stats", + "presentable": false, + "required": true, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "vo7iuj96", + "maxSelect": 1, + "name": "type", + "presentable": false, + "required": true, + "system": false, + "type": "select", + "values": [ + "1m", + "10m", + "20m", + "120m", + "480m" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE INDEX ` + "`" + `idx_systemd_stats` + "`" + ` ON ` + "`" + `systemd_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)" + ], + "system": false + }, { "id": "4afacsdnlu8q8r2", "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", diff --git a/internal/site/package-lock.json b/internal/site/package-lock.json index 2f10e3f6..de104805 100644 --- a/internal/site/package-lock.json +++ b/internal/site/package-lock.json @@ -69,7 +69,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -83,7 +83,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", @@ -98,7 +98,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -108,7 +108,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", @@ -139,7 +139,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", @@ -156,7 +156,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.27.2", @@ -173,7 +173,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -183,7 +183,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.27.1", @@ -197,7 +197,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", @@ -215,7 +215,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -225,7 +225,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -235,7 +235,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -245,7 +245,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", @@ -259,7 +259,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -287,7 +287,7 @@ "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -302,7 +302,7 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", @@ -321,7 +321,7 @@ "version": "7.28.2", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1020,7 +1020,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@sinclair/typebox": "^0.27.8" @@ -1033,7 +1033,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -1051,7 +1051,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1073,7 +1073,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1083,14 +1083,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1111,7 +1111,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/@lingui/babel-plugin-lingui-macro/-/babel-plugin-lingui-macro-5.4.1.tgz", "integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.20.12", @@ -1331,7 +1331,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-5.4.1.tgz", "integrity": "sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", @@ -2750,7 +2750,7 @@ "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@swc/core": { @@ -3420,14 +3420,14 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" @@ -3437,7 +3437,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" @@ -3447,7 +3447,7 @@ "version": "24.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.10.0" @@ -3457,7 +3457,7 @@ "version": "19.1.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz", "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -3467,7 +3467,7 @@ "version": "19.1.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -3477,7 +3477,7 @@ "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/yargs-parser": "*" @@ -3487,7 +3487,7 @@ "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@vitejs/plugin-react-swc": { @@ -3524,7 +3524,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3567,7 +3567,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -3662,7 +3662,7 @@ "version": "4.25.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -3733,7 +3733,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3743,7 +3743,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -3756,7 +3756,7 @@ "version": "1.0.30001727", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -3777,7 +3777,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -3889,7 +3889,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3902,7 +3902,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/colors": { @@ -3919,14 +3919,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "import-fresh": "^3.3.0", @@ -4106,7 +4106,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4176,7 +4176,7 @@ "version": "1.5.182", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.182.tgz", "integrity": "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/emoji-regex": { @@ -4204,7 +4204,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4273,7 +4273,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4361,7 +4361,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -4387,7 +4387,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4418,7 +4418,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -4461,7 +4461,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-binary-path": { @@ -4554,7 +4554,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -4564,7 +4564,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -4582,7 +4582,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -4604,7 +4604,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4617,7 +4617,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -4630,14 +4630,14 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -4650,7 +4650,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4899,7 +4899,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/lodash": { @@ -4948,7 +4948,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" @@ -5059,7 +5059,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5100,7 +5100,7 @@ "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/normalize-path": { @@ -5196,7 +5196,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -5209,7 +5209,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -5238,7 +5238,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5248,7 +5248,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5310,7 +5310,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/schemas": "^29.6.3", @@ -5325,7 +5325,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -5571,7 +5571,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -5669,7 +5669,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5852,7 +5852,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -5986,7 +5986,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6000,14 +6000,14 @@ "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "opencollective", @@ -6339,7 +6339,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, + "devOptional": true, "license": "ISC" } } diff --git a/internal/site/src/components/charts/systemd-services-table.tsx b/internal/site/src/components/charts/systemd-services-table.tsx new file mode 100644 index 00000000..2d5d018c --- /dev/null +++ b/internal/site/src/components/charts/systemd-services-table.tsx @@ -0,0 +1,195 @@ +import { SystemdService } from "@/types"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Trans } from "@lingui/react/macro"; +import { memo, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ChevronDownIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; + +interface SystemdServicesTableProps { + services: SystemdService[]; +} + +type SortKey = "name" | "status" | "cpu" | "mem"; + +const statusPriority: { [key: string]: number } = { + failed: 1, + activating: 2, + active: 3, + deactivating: 4, + inactive: 5, +}; + +const getStatusColor = (status: string) => { + switch (status) { + case 'active': + return 'text-green-500'; + case 'failed': + return 'text-red-500'; + case 'activating': + case 'reloading': + return 'text-blue-500'; + case 'inactive': + case 'deactivating': + return 'text-gray-500'; + default: + return ''; + } +}; + +const getStatusDotColor = (status: string) => { + switch (status) { + case 'active': + return 'bg-green-500'; + case 'failed': + return 'bg-red-500'; + case 'activating': + case 'reloading': + return 'bg-blue-500'; + case 'inactive': + case 'deactivating': + return 'bg-gray-500'; + default: + return 'bg-gray-400'; + } +}; + +export default memo(function SystemdServicesTable({ services }: SystemdServicesTableProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [filter, setFilter] = useState(""); + const [sortKey, setSortKey] = useState("status"); + const [sortAsc, setSortAsc] = useState(true); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortAsc(!sortAsc); + } else { + setSortKey(key); + setSortAsc(true); + } + }; + + const sortedServices = useMemo(() => { + return [...services].sort((a, b) => { + let compare = 0; + switch (sortKey) { + case "name": + compare = a.n.localeCompare(b.n); + break; + case "status": + const priorityA = statusPriority[a.s] || 99; + const priorityB = statusPriority[b.s] || 99; + compare = priorityA - priorityB; + if (compare === 0) { + compare = a.n.localeCompare(b.n); + } + break; + case "cpu": + compare = (a.c ?? 0) - (b.c ?? 0); + break; + case "mem": + compare = (a.m ?? 0) - (b.m ?? 0); + break; + } + return sortAsc ? compare : -compare; + }); + }, [services, sortKey, sortAsc]); + + const failedServices = useMemo(() => sortedServices.filter(s => s.s === 'failed'), [sortedServices]); + const activeServicesCount = useMemo(() => services.filter(s => s.s === 'active').length, [services]); + + const filteredServices = useMemo(() => { + if (!filter) { + return sortedServices; + } + return sortedServices.filter(service => service.n.toLowerCase().includes(filter.toLowerCase())); + }, [sortedServices, filter]); + + const servicesToShow = isExpanded ? filteredServices : failedServices; + + const summary = ( + + ({failedServices.length} failed, {activeServicesCount} active) + + ); + + const SortableHeader = ({ sortKey: key, children }: { sortKey: SortKey, children: React.ReactNode }) => ( + handleSort(key)} className="cursor-pointer"> +
+ {children} + +
+
+ ); + + return ( +
+
+

+ Systemd Services {summary} +

+ {isExpanded && ( +
+ setFilter(e.target.value)} + className="ps-4 pe-8" + /> + {filter && ( + + )} +
+ )} +
+
+ + + + Service + Status + CPU Usage + Memory + + + + {servicesToShow.map((service) => ( + + {service.n} + + + {service.s} + + {(service.c ?? 0).toFixed(2)}% + {(service.m ?? 0).toFixed(2)} MB + + ))} + +
+
+ {failedServices.length === 0 && !isExpanded && ( +
+ No failed services. +
+ )} +
+ +
+
+ ); +}) \ No newline at end of file diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 671871b8..4f269083 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -49,7 +49,7 @@ import { toFixedFloat, useBrowserStorage, } from "@/lib/utils" -import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" +import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord, SystemdStatsRecord } from "@/types" import ChartTimeSelect from "../charts/chart-time-select" import { $router, navigate } from "../router" import Spinner from "../spinner" @@ -62,6 +62,7 @@ import { Separator } from "../ui/separator" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip" import NetworkSheet from "./system/network-sheet" import LineChartDefault from "../charts/line-chart" +import SystemdServicesTable from "../charts/systemd-services-table" type ChartTimeData = { time: number @@ -95,7 +96,7 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) { } // add empty values between records to make gaps if interval is too large -function addEmptyValues( +function addValues( prevRecords: T[], newRecords: T[], expectedInterval: number @@ -119,7 +120,7 @@ function addEmptyValues( return modifiedRecords } -async function getStats( +async function getStats( collection: string, system: SystemRecord, chartTime: ChartTimes @@ -153,6 +154,7 @@ export default memo(function SystemDetail({ name }: { name: string }) { const [grid, setGrid] = useBrowserStorage("grid", true) const [system, setSystem] = useState({} as SystemRecord) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) + const [systemdStats, setSystemdStats] = useState([] as SystemdStatsRecord[]) const [containerData, setContainerData] = useState([] as ChartData["containerData"]) const netCardRef = useRef(null) const persistChartTime = useRef(false) @@ -171,6 +173,7 @@ export default memo(function SystemDetail({ name }: { name: string }) { } persistChartTime.current = false setSystemStats([]) + setSystemdStats([]) setContainerData([]) setContainerFilterBar(null) $containerFilter.set("") @@ -235,7 +238,8 @@ export default memo(function SystemDetail({ name }: { name: string }) { Promise.allSettled([ getStats("system_stats", system, chartTime), getStats("container_stats", system, chartTime), - ]).then(([systemStats, containerStats]) => { + getStats("systemd_stats", system, chartTime), + ]).then(([systemStats, containerStats, systemdStats]) => { // loading: false setChartLoading(false) @@ -244,18 +248,29 @@ export default memo(function SystemDetail({ name }: { name: string }) { const ss_cache_key = `${system.id}_${chartTime}_system_stats` let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[] if (systemStats.status === "fulfilled" && systemStats.value.length) { - systemData = systemData.concat(addEmptyValues(systemData, systemStats.value, expectedInterval)) + systemData = systemData.concat(addValues(systemData, systemStats.value, expectedInterval)) if (systemData.length > 120) { systemData = systemData.slice(-100) } cache.set(ss_cache_key, systemData) } setSystemStats(systemData) + // make new systemd stats + const sds_cache_key = `${system.id}_${chartTime}_systemd_stats` + let systemdData = (cache.get(sds_cache_key) || []) as SystemdStatsRecord[] + if (systemdStats.status === "fulfilled" && systemdStats.value.length) { + systemdData = systemdData.concat(addValues(systemdData, systemdStats.value, expectedInterval)) + if (systemdData.length > 120) { + systemdData = systemdData.slice(-100) + } + cache.set(sds_cache_key, systemdData) + } + setSystemdStats(systemdData) // make new container stats const cs_cache_key = `${system.id}_${chartTime}_container_stats` let containerData = (cache.get(cs_cache_key) || []) as ContainerStatsRecord[] if (containerStats.status === "fulfilled" && containerStats.value.length) { - containerData = containerData.concat(addEmptyValues(containerData, containerStats.value, expectedInterval)) + containerData = containerData.concat(addValues(containerData, containerStats.value, expectedInterval)) if (containerData.length > 120) { containerData = containerData.slice(-100) } @@ -847,6 +862,18 @@ export default memo(function SystemDetail({ name }: { name: string }) { )} + {/* systemd services table */} + {(systemdStats.at(-1)?.stats?.length ?? 0) > 0 && ( + + + Systemd Services + +
+ +
+
+ )} + {/* extra filesystem charts */} {Object.keys(systemStats.at(-1)?.stats.efs ?? {}).length > 0 && (
diff --git a/internal/site/src/components/systems-table/systems-table-columns.tsx b/internal/site/src/components/systems-table/systems-table-columns.tsx index 0849d303..9db547db 100644 --- a/internal/site/src/components/systems-table/systems-table-columns.tsx +++ b/internal/site/src/components/systems-table/systems-table-columns.tsx @@ -4,12 +4,15 @@ import { useStore } from "@nanostores/react" import { getPagePath } from "@nanostores/router" import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table" import type { ClassValue } from "clsx" +import type { SystemRecord, SystemStats, SystemStatsRecord, SystemdService, SystemdStatsRecord } from "@/types" import { ArrowUpDownIcon, ChevronRightSquareIcon, + CheckIcon, CopyIcon, CpuIcon, HardDriveIcon, + ListChecks, MemoryStickIcon, MoreHorizontalIcon, PauseCircleIcon, @@ -32,7 +35,6 @@ import { getMeterState, parseSemVer, } from "@/lib/utils" -import type { SystemRecord } from "@/types" import { SystemDialog } from "../add-system" import AlertButton from "../alerts/alert-button" import { $router, Link } from "../router" @@ -300,6 +302,15 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD ) }, }, + { + id: "systemd", + name: () => t`Services`, + size: 50, + Icon: ListChecks, + hideSort: true, + header: sortableHeader, + cell: ({ row }) => , + }, { id: "actions", // @ts-expect-error @@ -363,6 +374,46 @@ export function IndicatorDot({ system, className }: { system: SystemRecord; clas ) } +const SystemdCell = ({ systemId }: { systemId: string }) => { + const [stats, setStats] = useState(null); + + useEffect(() => { + const fetchStats = async () => { + try { + const record = await pb.collection("systemd_stats").getFirstListItem(`system="${systemId}"`, { + sort: "-created", + }); + setStats(record.stats); + } catch (error) { + // Handle case where no stats are found + setStats(null); + } + }; + + fetchStats(); + }, [systemId]); + + if (!stats) { + return -; + } + + const failed = stats.filter(s => s.s === 'failed').length; + + if (failed > 0) { + return ( +
+ {failed} +
+ ); + } + + return ( +
+ +
+ ); +}; + export const ActionsButton = memo(({ system }: { system: SystemRecord }) => { const [deleteOpen, setDeleteOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) diff --git a/internal/site/src/locales/en/en.po b/internal/site/src/locales/en/en.po index 05f3a6b0..d0fdf7b8 100644 --- a/internal/site/src/locales/en/en.po +++ b/internal/site/src/locales/en/en.po @@ -75,6 +75,10 @@ msgstr "5 min" msgid "Actions" msgstr "Actions" +#: src/components/charts/systemd-services-table.tsx +msgid "active" +msgstr "active" + #: src/components/alerts-history-columns.tsx #: src/components/routes/settings/alerts-history-data-table.tsx msgid "Active" @@ -343,6 +347,7 @@ msgstr "Copy YAML" msgid "CPU" msgstr "CPU" +#: src/components/charts/systemd-services-table.tsx #: src/components/routes/system.tsx #: src/components/routes/system.tsx #: src/lib/alerts.ts @@ -523,6 +528,10 @@ msgstr "Export your current systems configuration." msgid "Fahrenheit (°F)" msgstr "Fahrenheit (°F)" +#: src/components/charts/systemd-services-table.tsx +msgid "failed" +msgstr "failed" + #: src/lib/api.ts msgid "Failed to authenticate" msgstr "Failed to authenticate" @@ -680,6 +689,7 @@ msgstr "Manual setup instructions" msgid "Max 1 min" msgstr "Max 1 min" +#: src/components/charts/systemd-services-table.tsx #: src/components/systems-table/systems-table-columns.tsx msgid "Memory" msgstr "Memory" @@ -718,6 +728,10 @@ msgstr "Network traffic of public interfaces" msgid "Network unit" msgstr "Network unit" +#: src/components/charts/systemd-services-table.tsx +msgid "No failed services." +msgstr "No failed services." + #: src/components/command-palette.tsx msgid "No results found." msgstr "No results found." @@ -926,6 +940,14 @@ msgstr "See <0>notification settings to configure how you receive alerts." msgid "Sent" msgstr "Sent" +#: src/components/charts/systemd-services-table.tsx +msgid "Service" +msgstr "Service" + +#: src/components/systems-table/systems-table-columns.tsx +msgid "Services" +msgstr "Services" + #: src/components/routes/settings/general.tsx msgid "Set percentage thresholds for meter colors." msgstr "Set percentage thresholds for meter colors." @@ -941,6 +963,14 @@ msgstr "Settings" msgid "Settings saved" msgstr "Settings saved" +#: src/components/charts/systemd-services-table.tsx +msgid "Show all" +msgstr "Show all" + +#: src/components/charts/systemd-services-table.tsx +msgid "Show less" +msgstr "Show less" + #: src/components/login/auth-form.tsx msgid "Sign in" msgstr "Sign in" @@ -958,6 +988,7 @@ msgstr "Sort By" msgid "State" msgstr "State" +#: src/components/charts/systemd-services-table.tsx #: src/components/systems-table/systems-table.tsx #: src/lib/alerts.ts msgid "Status" @@ -982,6 +1013,11 @@ msgstr "System" msgid "System load averages over time" msgstr "System load averages over time" +#: src/components/charts/systemd-services-table.tsx +#: src/components/routes/system.tsx +msgid "Systemd Services" +msgstr "Systemd Services" + #: src/components/navbar.tsx msgid "Systems" msgstr "Systems" diff --git a/internal/site/src/locales/is/is.po b/internal/site/src/locales/is/is.po index 2eb59ee3..60515a6a 100644 --- a/internal/site/src/locales/is/is.po +++ b/internal/site/src/locales/is/is.po @@ -80,6 +80,10 @@ msgstr "" msgid "Actions" msgstr "Aðgerðir" +#: src/components/charts/systemd-services-table.tsx +msgid "active" +msgstr "" + #: src/components/alerts-history-columns.tsx #: src/components/routes/settings/alerts-history-data-table.tsx msgid "Active" @@ -348,6 +352,7 @@ msgstr "" msgid "CPU" msgstr "Örgjörvi" +#: src/components/charts/systemd-services-table.tsx #: src/components/routes/system.tsx #: src/components/routes/system.tsx #: src/lib/alerts.ts @@ -528,6 +533,10 @@ msgstr "" msgid "Fahrenheit (°F)" msgstr "" +#: src/components/charts/systemd-services-table.tsx +msgid "failed" +msgstr "" + #: src/lib/api.ts msgid "Failed to authenticate" msgstr "Villa í auðkenningu" @@ -685,6 +694,7 @@ msgstr "" msgid "Max 1 min" msgstr "Mest 1 mínúta" +#: src/components/charts/systemd-services-table.tsx #: src/components/systems-table/systems-table-columns.tsx msgid "Memory" msgstr "Minni" @@ -723,6 +733,10 @@ msgstr "" msgid "Network unit" msgstr "" +#: src/components/charts/systemd-services-table.tsx +msgid "No failed services." +msgstr "" + #: src/components/command-palette.tsx msgid "No results found." msgstr "Engar niðurstöður fundust." @@ -931,6 +945,14 @@ msgstr "" msgid "Sent" msgstr "Sent" +#: src/components/charts/systemd-services-table.tsx +msgid "Service" +msgstr "" + +#: src/components/systems-table/systems-table-columns.tsx +msgid "Services" +msgstr "" + #: src/components/routes/settings/general.tsx msgid "Set percentage thresholds for meter colors." msgstr "Stilltu prósentuþröskuld fyrir mælaliti." @@ -946,6 +968,14 @@ msgstr "Stillingar" msgid "Settings saved" msgstr "Stillingar vistaðar" +#: src/components/charts/systemd-services-table.tsx +msgid "Show all" +msgstr "" + +#: src/components/charts/systemd-services-table.tsx +msgid "Show less" +msgstr "" + #: src/components/login/auth-form.tsx msgid "Sign in" msgstr "Innskrá" @@ -963,6 +993,7 @@ msgstr "Raða eftir" msgid "State" msgstr "" +#: src/components/charts/systemd-services-table.tsx #: src/components/systems-table/systems-table.tsx #: src/lib/alerts.ts msgid "Status" @@ -987,6 +1018,11 @@ msgstr "Kerfi" msgid "System load averages over time" msgstr "" +#: src/components/charts/systemd-services-table.tsx +#: src/components/routes/system.tsx +msgid "Systemd Services" +msgstr "" + #: src/components/navbar.tsx msgid "Systems" msgstr "Kerfi" diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index 8656fb81..958410b6 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -147,6 +147,17 @@ export interface SystemStats { ni?: Record } +export interface SystemdService { + /** name */ + n: string + /** status */ + s: string + /** cpu percent */ + c: number + /** memory used (mb) */ + m: number +} + export interface GPUData { /** name */ n: string @@ -185,6 +196,12 @@ export interface ContainerStatsRecord extends RecordModel { created: string | number } +export interface SystemdStatsRecord extends RecordModel { + system: string + stats: SystemdService[] + created: string | number +} + interface ContainerStats { /** name */ n: string