mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-04 12:01:50 +02:00
Compare commits
1 Commits
b722ccc5bc
...
top-proces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbbdd49fc2 |
2
.github/workflows/docker-images.yml
vendored
2
.github/workflows/docker-images.yml
vendored
@@ -33,7 +33,6 @@ jobs:
|
|||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
type=raw,value=latest
|
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -100,7 +99,6 @@ jobs:
|
|||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
type=raw,value=latest
|
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ project_name: beszel
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate -run fetchsmartctl ./agent
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -7,7 +7,7 @@ SKIP_WEB ?= false
|
|||||||
# Set executable extension based on target OS
|
# Set executable extension based on target OS
|
||||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -46,14 +46,8 @@ build-dotnet-conditional:
|
|||||||
fi; \
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download smartctl.exe at build time for Windows (skips if already present)
|
|
||||||
fetch-smartctl-conditional:
|
|
||||||
@if [ "$(OS)" = "windows" ]; then \
|
|
||||||
go generate -run fetchsmartctl ./agent; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update build-agent to include conditional .NET build
|
# Update build-agent to include conditional .NET build
|
||||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
build-agent: tidy build-dotnet-conditional
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ 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.
|
||||||
@@ -102,11 +101,6 @@ 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)
|
||||||
@@ -160,13 +154,7 @@ 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)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
@@ -175,11 +163,6 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
// Add percentages to Info struct for dashboard
|
|
||||||
if stats.DiskTotal > 0 {
|
|
||||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
|
||||||
data.Info.ExtraFsPct[key] = pct
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package battery
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
@@ -52,8 +51,6 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
errs, partialErrs := err.(battery.Errors)
|
errs, partialErrs := err.(battery.Errors)
|
||||||
|
|
||||||
batteryState = math.MaxUint8
|
|
||||||
|
|
||||||
for i, bat := range batteries {
|
for i, bat := range batteries {
|
||||||
if partialErrs && errs[i] != nil {
|
if partialErrs && errs[i] != nil {
|
||||||
// if there were some errors, like missing data, skip it
|
// if there were some errors, like missing data, skip it
|
||||||
@@ -66,12 +63,9 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
}
|
}
|
||||||
totalCapacity += bat.Full
|
totalCapacity += bat.Full
|
||||||
totalCharge += bat.Current
|
totalCharge += bat.Current
|
||||||
if bat.State.Raw >= 0 {
|
|
||||||
batteryState = uint8(bat.State.Raw)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
if totalCapacity == 0 {
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
// for macs there's sometimes a ghost battery with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||||
@@ -80,5 +74,6 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
|
batteryState = uint8(batteries[0].State.Raw)
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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"
|
||||||
@@ -277,8 +276,6 @@ 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:
|
||||||
|
|||||||
119
agent/cpu.go
119
agent/cpu.go
@@ -2,14 +2,19 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
"github.com/shirou/gopsutil/v4/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
||||||
|
var lastProcessCpuTimes = make(map[uint16]map[int32]float64)
|
||||||
|
var lastProcessCpuSampleTime = make(map[uint16]time.Time)
|
||||||
|
|
||||||
// init initializes the CPU monitoring by storing the initial CPU times
|
// init initializes the CPU monitoring by storing the initial CPU times
|
||||||
// for the default 60-second cache interval.
|
// for the default 60-second cache interval.
|
||||||
@@ -20,6 +25,16 @@ func init() {
|
|||||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
if perCoreTimes, err := cpu.Times(true); err == nil {
|
||||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
lastPerCoreCpuTimes[60000] = perCoreTimes
|
||||||
}
|
}
|
||||||
|
if processes, err := process.Processes(); err == nil {
|
||||||
|
snapshot := make(map[int32]float64, len(processes))
|
||||||
|
for _, proc := range processes {
|
||||||
|
if times, err := proc.Times(); err == nil {
|
||||||
|
snapshot[proc.Pid] = times.Total()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastProcessCpuTimes[60000] = snapshot
|
||||||
|
lastProcessCpuSampleTime[60000] = time.Now()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CpuMetrics contains detailed CPU usage breakdown
|
// CpuMetrics contains detailed CPU usage breakdown
|
||||||
@@ -105,6 +120,110 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|||||||
return usage, nil
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTopCpuProcess returns the process with the highest CPU usage since the last run
|
||||||
|
// for the given cache interval. It returns nil if insufficient data is available.
|
||||||
|
func getTopCpuProcess(cacheTimeMs uint16) (*system.TopCpuProcess, error) {
|
||||||
|
processes, err := process.Processes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
lastTimes, ok := lastProcessCpuTimes[cacheTimeMs]
|
||||||
|
if !ok {
|
||||||
|
if fallback := lastProcessCpuTimes[60000]; fallback != nil {
|
||||||
|
copied := make(map[int32]float64, len(fallback))
|
||||||
|
for pid, total := range fallback {
|
||||||
|
copied[pid] = total
|
||||||
|
}
|
||||||
|
lastTimes = copied
|
||||||
|
lastProcessCpuTimes[cacheTimeMs] = copied
|
||||||
|
} else {
|
||||||
|
lastTimes = make(map[int32]float64)
|
||||||
|
lastProcessCpuTimes[cacheTimeMs] = lastTimes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSample := lastProcessCpuSampleTime[cacheTimeMs]
|
||||||
|
if lastSample.IsZero() {
|
||||||
|
if fallback := lastProcessCpuSampleTime[60000]; !fallback.IsZero() {
|
||||||
|
lastSample = fallback
|
||||||
|
lastProcessCpuSampleTime[cacheTimeMs] = fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := now.Sub(lastSample).Seconds()
|
||||||
|
if lastSample.IsZero() || elapsed <= 0 {
|
||||||
|
snapshot := make(map[int32]float64, len(processes))
|
||||||
|
for _, proc := range processes {
|
||||||
|
if times, err := proc.Times(); err == nil {
|
||||||
|
snapshot[proc.Pid] = times.Total()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastProcessCpuTimes[cacheTimeMs] = snapshot
|
||||||
|
lastProcessCpuSampleTime[cacheTimeMs] = now
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cpuCount := float64(runtime.NumCPU())
|
||||||
|
if cpuCount <= 0 {
|
||||||
|
cpuCount = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := make(map[int32]float64, len(processes))
|
||||||
|
var topName string
|
||||||
|
var topPercent float64
|
||||||
|
|
||||||
|
for _, proc := range processes {
|
||||||
|
times, err := proc.Times()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
total := times.Total()
|
||||||
|
pid := proc.Pid
|
||||||
|
snapshot[pid] = total
|
||||||
|
|
||||||
|
lastTotal, ok := lastTimes[pid]
|
||||||
|
if !ok || total <= lastTotal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
percent := clampPercent((total - lastTotal) / (elapsed * cpuCount) * 100)
|
||||||
|
if percent <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := proc.Name()
|
||||||
|
if err != nil || name == "" {
|
||||||
|
if exe, exeErr := proc.Exe(); exeErr == nil && exe != "" {
|
||||||
|
name = filepath.Base(exe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if percent > topPercent {
|
||||||
|
topPercent = percent
|
||||||
|
topName = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastProcessCpuTimes[cacheTimeMs] = snapshot
|
||||||
|
lastProcessCpuSampleTime[cacheTimeMs] = now
|
||||||
|
|
||||||
|
if topName == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &system.TopCpuProcess{
|
||||||
|
Name: topName,
|
||||||
|
Percent: topPercent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||||
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
||||||
// returning a percentage clamped between 0 and 100.
|
// returning a percentage clamped between 0 and 100.
|
||||||
|
|||||||
@@ -134,9 +134,7 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
// Collect engine names from header1
|
// Collect engine names from header1
|
||||||
for _, col := range h1 {
|
for _, col := range h1 {
|
||||||
key := strings.TrimRightFunc(col, func(r rune) bool {
|
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
|
||||||
return (r >= '0' && r <= '9') || r == '/'
|
|
||||||
})
|
|
||||||
var friendly string
|
var friendly string
|
||||||
switch key {
|
switch key {
|
||||||
case "RCS":
|
case "RCS":
|
||||||
|
|||||||
@@ -1439,15 +1439,6 @@ func TestParseIntelHeaders(t *testing.T) {
|
|||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "basic headers with RCS BCS VCS using index in name",
|
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
|
||||||
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
|
||||||
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
|
||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "headers with only RCS",
|
name: "headers with only RCS",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -175,31 +174,3 @@ 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,7 +15,6 @@ 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"
|
||||||
@@ -174,8 +173,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
111
agent/smart.go
111
agent/smart.go
@@ -1,6 +1,3 @@
|
|||||||
//go:generate -command fetchsmartctl go run ./tools/fetchsmartctl
|
|
||||||
//go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,9 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -24,12 +19,10 @@ import (
|
|||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
binPath string
|
|
||||||
excludedDevices map[string]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -167,7 +160,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
|
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -186,7 +179,6 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||||
finalDevices = sm.filterExcludedDevices(finalDevices)
|
|
||||||
sm.updateSmartDevices(finalDevices)
|
sm.updateSmartDevices(finalDevices)
|
||||||
|
|
||||||
if len(finalDevices) == 0 {
|
if len(finalDevices) == 0 {
|
||||||
@@ -234,47 +226,6 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
return devices, nil
|
return devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SmartManager) refreshExcludedDevices() {
|
|
||||||
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
|
||||||
sm.excludedDevices = make(map[string]struct{})
|
|
||||||
|
|
||||||
for entry := range strings.SplitSeq(rawValue, ",") {
|
|
||||||
device := strings.TrimSpace(entry)
|
|
||||||
if device == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sm.excludedDevices[device] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
|
|
||||||
_, exists := sm.excludedDevices[deviceName]
|
|
||||||
return exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
|
|
||||||
if devices == nil {
|
|
||||||
return []*DeviceInfo{}
|
|
||||||
}
|
|
||||||
|
|
||||||
excluded := sm.excludedDevices
|
|
||||||
if len(excluded) == 0 {
|
|
||||||
return devices
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered := make([]*DeviceInfo, 0, len(devices))
|
|
||||||
for _, device := range devices {
|
|
||||||
if device == nil || device.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, skip := excluded[device.Name]; skip {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filtered = append(filtered, device)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectSmartOutputType inspects sections that are unique to each smartctl
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
// when the reported device type is ambiguous or missing.
|
// when the reported device type is ambiguous or missing.
|
||||||
@@ -421,10 +372,6 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
// for initial data collection when no cached data exists
|
// for initial data collection when no cached data exists
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
|
||||||
return errNoValidSmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
// Check if we have any existing data for this device
|
||||||
@@ -435,7 +382,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
|
|
||||||
// Try with -n standby first if we have existing data
|
// Try with -n standby first if we have existing data
|
||||||
args := sm.smartctlArgs(deviceInfo, true)
|
args := sm.smartctlArgs(deviceInfo, true)
|
||||||
cmd := exec.CommandContext(ctx, sm.binPath, args...)
|
cmd := exec.CommandContext(ctx, "smartctl", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
// Check if device is in standby (exit status 2)
|
// Check if device is in standby (exit status 2)
|
||||||
@@ -448,7 +395,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel2()
|
defer cancel2()
|
||||||
args = sm.smartctlArgs(deviceInfo, false)
|
args = sm.smartctlArgs(deviceInfo, false)
|
||||||
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
cmd = exec.CommandContext(ctx2, "smartctl", args...)
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,10 +403,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
|
|
||||||
if !hasValidData {
|
if !hasValidData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,33 +875,13 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
func (sm *SmartManager) detectSmartctl() (string, error) {
|
func (sm *SmartManager) detectSmartctl() error {
|
||||||
isWindows := runtime.GOOS == "windows"
|
if _, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
slog.Debug("smartctl found")
|
||||||
// Load embedded smartctl.exe for Windows amd64 builds.
|
return nil
|
||||||
if isWindows && runtime.GOARCH == "amd64" {
|
|
||||||
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
slog.Debug("smartctl not found")
|
||||||
if path, err := exec.LookPath("smartctl"); err == nil {
|
return errors.New("smartctl not found")
|
||||||
return path, nil
|
|
||||||
}
|
|
||||||
locations := []string{}
|
|
||||||
if isWindows {
|
|
||||||
locations = append(locations,
|
|
||||||
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
|
||||||
}
|
|
||||||
for _, location := range locations {
|
|
||||||
if _, err := os.Stat(location); err == nil {
|
|
||||||
return location, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", errors.New("smartctl not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSmartManager creates and initializes a new SmartManager
|
// NewSmartManager creates and initializes a new SmartManager
|
||||||
@@ -962,13 +889,9 @@ func NewSmartManager() (*SmartManager, error) {
|
|||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
sm.refreshExcludedDevices()
|
if err := sm.detectSmartctl(); err != nil {
|
||||||
path, err := sm.detectSmartctl()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug(err.Error())
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
slog.Debug("smartctl", "path", path)
|
|
||||||
sm.binPath = path
|
|
||||||
return sm, nil
|
return sm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
return "", errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
@@ -588,195 +588,3 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRefreshExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
envValue string
|
|
||||||
expectedDevs map[string]struct{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty env",
|
|
||||||
envValue: "",
|
|
||||||
expectedDevs: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single device",
|
|
||||||
envValue: "/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "devices with whitespace",
|
|
||||||
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty entries and whitespace",
|
|
||||||
envValue: "/dev/sda,, /dev/sdb , , ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envValue != "" {
|
|
||||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
|
||||||
} else {
|
|
||||||
// Ensure env var is not set for empty test
|
|
||||||
os.Unsetenv("EXCLUDE_SMART")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
sm.refreshExcludedDevices()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExcludedDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
deviceName string
|
|
||||||
expectedBool bool
|
|
||||||
}{
|
|
||||||
{"excluded device sda", "/dev/sda", true},
|
|
||||||
{"excluded device nvme0", "/dev/nvme0", true},
|
|
||||||
{"non-excluded device sdb", "/dev/sdb", false},
|
|
||||||
{"non-excluded device nvme1", "/dev/nvme1", false},
|
|
||||||
{"empty device name", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := sm.isExcludedDevice(tt.deviceName)
|
|
||||||
assert.Equal(t, tt.expectedBool, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
excludedDevs map[string]struct{}
|
|
||||||
inputDevices []*DeviceInfo
|
|
||||||
expectedDevs []*DeviceInfo
|
|
||||||
expectedLength int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no exclusions",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedLength: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "some devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedLength: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil devices",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: nil,
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter nil and empty name devices",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
nil,
|
|
||||||
{Name: ""},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedLength: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: tt.excludedDevs,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := sm.filterExcludedDevices(tt.inputDevices)
|
|
||||||
|
|
||||||
assert.Len(t, result, tt.expectedLength)
|
|
||||||
assert.Equal(t, tt.expectedDevs, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed smartmontools/smartctl.exe
|
|
||||||
var embeddedSmartctl []byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
smartctlOnce sync.Once
|
|
||||||
smartctlPath string
|
|
||||||
smartctlErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
smartctlOnce.Do(func() {
|
|
||||||
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
|
|
||||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, "smartctl.exe")
|
|
||||||
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
smartctlPath = destPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return smartctlPath, smartctlErr
|
|
||||||
}
|
|
||||||
@@ -98,6 +98,15 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
slog.Error("Error getting cpu metrics", "err", err)
|
slog.Error("Error getting cpu metrics", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if topProcess, err := getTopCpuProcess(cacheTimeMs); err == nil {
|
||||||
|
if topProcess != nil {
|
||||||
|
topProcess.Percent = twoDecimals(topProcess.Percent)
|
||||||
|
systemStats.TopCpuProcess = topProcess
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Error("Error getting top cpu process", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
// per-core cpu usage
|
// per-core cpu usage
|
||||||
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
||||||
systemStats.CpuCoresUsage = perCoreUsage
|
systemStats.CpuCoresUsage = perCoreUsage
|
||||||
|
|||||||
229
agent/systemd.go
229
agent/systemd.go
@@ -1,229 +0,0 @@
|
|||||||
//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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//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")
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
//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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Download smartctl.exe from the given URL and save it to the given destination.
|
|
||||||
// This is used to embed smartctl.exe in the Windows build.
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
|
|
||||||
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
|
|
||||||
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
|
|
||||||
force := flag.Bool("force", false, "Force re-download even if destination exists")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *url == "" || *out == "" {
|
|
||||||
fatalf("-url and -out are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*force {
|
|
||||||
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
|
|
||||||
fmt.Println("smartctl.exe already present, skipping download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloadFile(*url, *out, *sha); err != nil {
|
|
||||||
fatalf("download failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(url, dest, shaHex string) error {
|
|
||||||
// Prepare destination
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP client
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("new request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("http get: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp := dest + ".tmp"
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
|
|
||||||
var hasher hash.Hash
|
|
||||||
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
|
|
||||||
switch len(cleanSha) {
|
|
||||||
case 40:
|
|
||||||
hasher = sha1.New()
|
|
||||||
case 64:
|
|
||||||
hasher = sha256.New()
|
|
||||||
default:
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mw io.Writer = f
|
|
||||||
if hasher != nil {
|
|
||||||
mw = io.MultiWriter(f, hasher)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(mw, resp.Body); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("write tmp: %w", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("close tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasher != nil && shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
|
|
||||||
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
|
|
||||||
if got != cleanSha {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make executable and move into place
|
|
||||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("chmod: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("rename: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("smartctl.exe downloaded to", dest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fatalf(format string, a ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -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.16.0-beta.1"
|
Version = "0.15.3"
|
||||||
// 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,7 +4,6 @@ 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
|
||||||
@@ -38,7 +37,6 @@ 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,8 +9,6 @@ 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=
|
||||||
@@ -51,8 +49,6 @@ 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=
|
||||||
|
|||||||
@@ -40,18 +40,13 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
|
||||||
Usage float64 `json:"u"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
|
|||||||
@@ -161,14 +161,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// Get system ID for the link
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
systemID := alertRecord.GetString("system")
|
// return errs["user"]
|
||||||
|
// }
|
||||||
|
// user := alertRecord.ExpandedOne("user")
|
||||||
|
// if user == nil {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "GPU":
|
|
||||||
val = data.Info.GpuPct
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
@@ -208,17 +206,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
case "GPU":
|
|
||||||
if len(stats.GPU) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
maxUsage := 0.0
|
|
||||||
for _, gpu := range stats.GPU {
|
|
||||||
if gpu.Usage > maxUsage {
|
|
||||||
maxUsage = gpu.Usage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val += maxUsage
|
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -281,9 +268,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU or GPU
|
// make title alert name lowercase if not CPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
if titleAlertName != "CPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,7 +298,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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
|
||||||
@@ -19,8 +18,6 @@ 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...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +36,6 @@ 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"`
|
||||||
}
|
}
|
||||||
@@ -69,7 +65,3 @@ 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,7 +7,6 @@ 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 {
|
||||||
@@ -48,6 +47,7 @@ type Stats struct {
|
|||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
|
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
|
||||||
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
||||||
|
TopCpuProcess *TopCpuProcess `json:"tcp,omitempty" cbor:"35,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
||||||
@@ -144,15 +144,18 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
}
|
||||||
|
|
||||||
|
type TopCpuProcess struct {
|
||||||
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
|
Percent float64 `json:"p" cbor:"1,keyasint"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
//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,8 +270,6 @@ 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
|
||||||
@@ -344,27 +342,6 @@ 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,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,7 +15,6 @@ 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"
|
||||||
|
|
||||||
@@ -173,14 +171,6 @@ 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)
|
||||||
|
|
||||||
@@ -194,50 +184,11 @@ 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(),
|
||||||
@@ -389,52 +340,6 @@ 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
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//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,7 +7,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -116,44 +115,6 @@ 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() {
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//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())
|
|
||||||
}
|
|
||||||
@@ -75,7 +75,6 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
"GPU",
|
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15"
|
||||||
@@ -1008,148 +1007,6 @@ 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
|
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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,10 +490,6 @@ 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
|
||||||
@@ -563,20 +559,6 @@ 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,83 +351,6 @@ 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())
|
||||||
|
|||||||
4
internal/site/package-lock.json
generated
4
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.15.4",
|
"version": "0.15.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.15.4",
|
"version": "0.15.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.4",
|
"version": "0.15.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export default function AreaChartDefault({
|
|||||||
domain,
|
domain,
|
||||||
legend,
|
legend,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
showTotal = false,
|
|
||||||
reverseStackOrder = false,
|
reverseStackOrder = false,
|
||||||
hideYAxis = false,
|
hideYAxis = false,
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
@@ -43,7 +42,6 @@ export default function AreaChartDefault({
|
|||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
domain?: [number, number]
|
domain?: [number, number]
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
showTotal?: boolean
|
|
||||||
itemSorter?: (a: any, b: any) => number
|
itemSorter?: (a: any, b: any) => number
|
||||||
reverseStackOrder?: boolean
|
reverseStackOrder?: boolean
|
||||||
hideYAxis?: boolean
|
hideYAxis?: boolean
|
||||||
@@ -67,25 +65,18 @@ export default function AreaChartDefault({
|
|||||||
"ps-4": hideYAxis,
|
"ps-4": hideYAxis,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart reverseStackOrder={reverseStackOrder} accessibilityLayer data={chartData.systemStats} margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}>
|
||||||
reverseStackOrder={reverseStackOrder}
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData.systemStats}
|
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{!hideYAxis && (
|
{!hideYAxis && <YAxis
|
||||||
<YAxis
|
direction="ltr"
|
||||||
direction="ltr"
|
orientation={chartData.orientation}
|
||||||
orientation={chartData.orientation}
|
className="tracking-tighter"
|
||||||
className="tracking-tighter"
|
width={yAxisWidth}
|
||||||
width={yAxisWidth}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
domain={domain ?? [0, max ?? "auto"]}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
/>}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
@@ -96,7 +87,6 @@ export default function AreaChartDefault({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
showTotal={showTotal}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -124,5 +114,5 @@ export default function AreaChartDefault({
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}%`
|
const val = toFixedFloat(value, 2) + unit
|
||||||
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) {
|
||||||
@@ -139,7 +139,7 @@ export default memo(function ContainerChart({
|
|||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filteredKeys.has(key)
|
const filtered = filteredKeys.has(key)
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
|
|||||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
const syntaxTheme = "github-dark-dimmed"
|
const syntaxTheme = "github-dark-dimmed"
|
||||||
|
|
||||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||||
const loadTime = Date.now()
|
|
||||||
const [data, setData] = useState<ContainerRecord[]>([])
|
const [data, setData] = useState<ContainerRecord[]>([])
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
`sort-c-${systemId ? 1 : 0}`,
|
`sort-c-${systemId ? 1 : 0}`,
|
||||||
@@ -48,53 +47,56 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function fetchData(systemId?: string) {
|
const pbOptions = {
|
||||||
|
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = (lastXMs: number) => {
|
||||||
|
const updated = Date.now() - lastXMs
|
||||||
|
let filter: string
|
||||||
|
if (systemId) {
|
||||||
|
filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated })
|
||||||
|
} else {
|
||||||
|
filter = pb.filter("updated > {:updated}", { updated })
|
||||||
|
}
|
||||||
pb.collection<ContainerRecord>("containers")
|
pb.collection<ContainerRecord>("containers")
|
||||||
.getList(0, 2000, {
|
.getList(0, 2000, {
|
||||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
...pbOptions,
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
filter,
|
||||||
})
|
})
|
||||||
.then(({ items }) => items.length && setData((curItems) => {
|
.then(({ items }) => setData((curItems) => {
|
||||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
const containerIds = new Set(items.map(item => item.id))
|
||||||
const containerIds = new Set()
|
const now = Date.now()
|
||||||
const newItems = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
|
||||||
containerIds.add(item.id)
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of curItems) {
|
for (const item of curItems) {
|
||||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
|
||||||
newItems.push(item)
|
items.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newItems
|
return items
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial load
|
// initial load
|
||||||
fetchData(systemId)
|
fetchData(70_000)
|
||||||
|
|
||||||
// if no systemId, pull system containers after every system update
|
// if no systemId, poll every 10 seconds
|
||||||
if (!systemId) {
|
if (!systemId) {
|
||||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
// poll every 10 seconds
|
||||||
// exclude initial load of systems
|
const intervalId = setInterval(() => fetchData(10_500), 10_000)
|
||||||
if (Date.now() - loadTime > 500) {
|
// clear interval on unmount
|
||||||
fetchData(systemId)
|
return () => clearInterval(intervalId)
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if systemId, fetch containers after the system is updated
|
// if systemId, fetch containers after the system is updated
|
||||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||||
fetchData(systemId)
|
const changeTime = Date.now()
|
||||||
|
setTimeout(() => fetchData(Date.now() - changeTime + 1000), 100)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
@@ -162,78 +164,77 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllContainersTable = memo(function AllContainersTable({
|
const AllContainersTable = memo(
|
||||||
table,
|
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
|
||||||
rows,
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
colLength,
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
}: {
|
const activeContainer = useRef<ContainerRecord | null>(null)
|
||||||
table: TableType<ContainerRecord>
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
rows: Row<ContainerRecord>[]
|
const openSheet = (container: ContainerRecord) => {
|
||||||
colLength: number
|
activeContainer.current = container
|
||||||
}) {
|
setSheetOpen(true)
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
}
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const activeContainer = useRef<ContainerRecord | null>(null)
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const openSheet = (container: ContainerRecord) => {
|
|
||||||
activeContainer.current = container
|
|
||||||
setSheetOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
count: rows.length,
|
count: rows.length,
|
||||||
estimateSize: () => 54,
|
estimateSize: () => 54,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
overscan: 5,
|
overscan: 5,
|
||||||
})
|
})
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
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))
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
"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
|
// 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"
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
)}
|
)}
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
>
|
>
|
||||||
{/* add header height to table size */}
|
{/* add header height to table size */}
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
<table className="text-sm w-full h-full text-nowrap">
|
<table className="text-sm w-full h-full text-nowrap">
|
||||||
<ContainersTableHead table={table} />
|
<ContainersTableHead table={table} />
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.length ? (
|
{rows.length ? (
|
||||||
virtualRows.map((virtualRow) => {
|
virtualRows.map((virtualRow) => {
|
||||||
const row = rows[virtualRow.index]
|
const row = rows[virtualRow.index]
|
||||||
return <ContainerTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
return (
|
||||||
})
|
<ContainerTableRow
|
||||||
) : (
|
key={row.id}
|
||||||
<TableRow>
|
row={row}
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
virtualRow={virtualRow}
|
||||||
<Trans>No results.</Trans>
|
openSheet={openSheet}
|
||||||
</TableCell>
|
/>
|
||||||
</TableRow>
|
)
|
||||||
)}
|
})
|
||||||
</TableBody>
|
) : (
|
||||||
</table>
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
||||||
</div>
|
</div>
|
||||||
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
)
|
||||||
</div>
|
}
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const [{ highlighter }, logsHtml] = await Promise.all([
|
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
||||||
import("@/lib/shiki"),
|
system: container.system,
|
||||||
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
container: container.id,
|
||||||
system: container.system,
|
})])
|
||||||
container: container.id,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -243,13 +244,10 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
|||||||
|
|
||||||
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
||||||
try {
|
try {
|
||||||
let [{ highlighter }, { info }] = await Promise.all([
|
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
|
||||||
import("@/lib/shiki"),
|
system: container.system,
|
||||||
pb.send<{ info: string }>("/api/beszel/containers/info", {
|
container: container.id,
|
||||||
system: container.system,
|
})])
|
||||||
container: container.id,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
try {
|
try {
|
||||||
info = JSON.stringify(JSON.parse(info), null, 2)
|
info = JSON.stringify(JSON.parse(info), null, 2)
|
||||||
} catch (_) { }
|
} catch (_) { }
|
||||||
@@ -260,15 +258,7 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerSheet({
|
function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject<ContainerRecord | null> }) {
|
||||||
sheetOpen,
|
|
||||||
setSheetOpen,
|
|
||||||
activeContainer,
|
|
||||||
}: {
|
|
||||||
sheetOpen: boolean
|
|
||||||
setSheetOpen: (open: boolean) => void
|
|
||||||
activeContainer: RefObject<ContainerRecord | null>
|
|
||||||
}) {
|
|
||||||
const container = activeContainer.current
|
const container = activeContainer.current
|
||||||
if (!container) return null
|
if (!container) return null
|
||||||
|
|
||||||
@@ -307,14 +297,14 @@ function ContainerSheet({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogsDisplay("")
|
setLogsDisplay("")
|
||||||
setInfoDisplay("")
|
setInfoDisplay("");
|
||||||
if (!container) return
|
if (!container) return
|
||||||
; (async () => {
|
(async () => {
|
||||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||||
setLogsDisplay(logsHtml)
|
setLogsDisplay(logsHtml)
|
||||||
setInfoDisplay(infoHtml)
|
setInfoDisplay(infoHtml)
|
||||||
setTimeout(scrollLogsToBottom, 20)
|
setTimeout(scrollLogsToBottom, 20)
|
||||||
})()
|
})()
|
||||||
}, [container])
|
}, [container])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -338,9 +328,7 @@ function ContainerSheet({
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{container.name}</SheetTitle>
|
<SheetTitle>{container.name}</SheetTitle>
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
|
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
|
||||||
{$allSystemsById.get()[container.system]?.name ?? ""}
|
|
||||||
</Link>
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
{container.status}
|
{container.status}
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
@@ -362,20 +350,19 @@ function ContainerSheet({
|
|||||||
disabled={isRefreshingLogs}
|
disabled={isRefreshingLogs}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon
|
||||||
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
|
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogsFullscreenOpen(true)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
<MaximizeIcon className="size-4" />
|
<MaximizeIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
|
||||||
ref={logsContainerRef}
|
|
||||||
className={cn(
|
|
||||||
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
|
||||||
!logsDisplay && ["animate-pulse", "h-full"]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
@@ -389,18 +376,15 @@ function ContainerSheet({
|
|||||||
<MaximizeIcon className="size-4" />
|
<MaximizeIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className={cn("grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm", !infoDisplay && "animate-pulse")}>
|
||||||
className={cn(
|
|
||||||
"grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
|
|
||||||
!infoDisplay && "animate-pulse"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,51 +406,39 @@ function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerTableRow = memo(function ContainerTableRow({
|
const ContainerTableRow = memo(
|
||||||
row,
|
function ContainerTableRow({
|
||||||
virtualRow,
|
row,
|
||||||
openSheet,
|
virtualRow,
|
||||||
}: {
|
openSheet,
|
||||||
row: Row<ContainerRecord>
|
}: {
|
||||||
virtualRow: VirtualItem
|
row: Row<ContainerRecord>
|
||||||
openSheet: (container: ContainerRecord) => void
|
virtualRow: VirtualItem
|
||||||
}) {
|
openSheet: (container: ContainerRecord) => void
|
||||||
return (
|
}) {
|
||||||
<TableRow
|
return (
|
||||||
data-state={row.getIsSelected() && "selected"}
|
<TableRow
|
||||||
className="cursor-pointer transition-opacity"
|
data-state={row.getIsSelected() && "selected"}
|
||||||
onClick={() => openSheet(row.original)}
|
className="cursor-pointer transition-opacity"
|
||||||
>
|
onClick={() => openSheet(row.original)}
|
||||||
{row.getVisibleCells().map((cell) => (
|
>
|
||||||
<TableCell
|
{row.getVisibleCells().map((cell) => (
|
||||||
key={cell.id}
|
<TableCell
|
||||||
className="py-0"
|
key={cell.id}
|
||||||
style={{
|
className="py-0"
|
||||||
height: virtualRow.size,
|
style={{
|
||||||
}}
|
height: virtualRow.size,
|
||||||
>
|
}}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
>
|
||||||
</TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
))}
|
</TableCell>
|
||||||
</TableRow>
|
))}
|
||||||
)
|
</TableRow>
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function LogsFullscreenDialog({
|
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
logsDisplay,
|
|
||||||
containerName,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
logsDisplay: string
|
|
||||||
containerName: string
|
|
||||||
onRefresh: () => void | Promise<void>
|
|
||||||
isRefreshing: boolean
|
|
||||||
}) {
|
|
||||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
const outerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -499,24 +471,16 @@ function LogsFullscreenDialog({
|
|||||||
title={t`Refresh`}
|
title={t`Refresh`}
|
||||||
aria-label={t`Refresh`}
|
aria-label={t`Refresh`}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon className={`size-4 transition-transform duration-300 ${isRefreshing ? "animate-spin" : ""}`} />
|
<RefreshCwIcon
|
||||||
|
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoFullscreenDialog({
|
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
infoDisplay,
|
|
||||||
containerName,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
infoDisplay: string
|
|
||||||
containerName: string
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
compareSemVer,
|
compareSemVer,
|
||||||
|
debounce,
|
||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
secondsToString,
|
secondsToString,
|
||||||
@@ -75,6 +76,8 @@ 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: {
|
||||||
@@ -696,7 +699,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -750,7 +752,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
showTotal={true}
|
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -1006,11 +1007,7 @@ 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={system.id} />
|
<LazyContainersTable systemId={id} />
|
||||||
)}
|
|
||||||
|
|
||||||
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
|
||||||
<LazySystemdTable systemId={system.id} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1043,51 +1040,32 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const storeValue = useStore(store)
|
const containerFilter = useStore(store)
|
||||||
const [inputValue, setInputValue] = useState(storeValue)
|
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store])
|
||||||
setInputValue(storeValue)
|
|
||||||
}, [storeValue])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputValue === storeValue) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const handle = window.setTimeout(() => store.set(inputValue), 80)
|
|
||||||
return () => clearTimeout(handle)
|
|
||||||
}, [inputValue, storeValue, store])
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => debouncedStoreSet(e.target.value),
|
||||||
const value = e.target.value
|
[debouncedStoreSet]
|
||||||
setInputValue(value)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
|
||||||
setInputValue("")
|
|
||||||
store.set("")
|
|
||||||
}, [store])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
className="ps-4 pe-8 w-full sm:w-44"
|
className="ps-4 pe-8 w-full sm:w-44"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
value={inputValue}
|
value={containerFilter}
|
||||||
/>
|
/>
|
||||||
{inputValue && (
|
{containerFilter && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
onClick={handleClear}
|
onClick={() => store.set("")}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1181,15 +1159,4 @@ 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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,665 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
@@ -154,7 +153,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
accessorFn: ({ info }) => info.dp,
|
accessorFn: ({ info }) => info.dp,
|
||||||
id: "disk",
|
id: "disk",
|
||||||
name: () => t`Disk`,
|
name: () => t`Disk`,
|
||||||
cell: DiskCellWithMultiple,
|
cell: TableCellWithMeter,
|
||||||
Icon: HardDriveIcon,
|
Icon: HardDriveIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
@@ -355,79 +354,6 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
|
||||||
const { info: sysInfo, status, id } = info.row.original
|
|
||||||
const extraFs = Object.entries(sysInfo.efs ?? {})
|
|
||||||
|
|
||||||
// No extra disks - show basic meter
|
|
||||||
if (extraFs.length === 0) {
|
|
||||||
return TableCellWithMeter(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootDiskPct = sysInfo.dp
|
|
||||||
|
|
||||||
// sort extra disks by percentage descending
|
|
||||||
extraFs.sort((a, b) => b[1] - a[1])
|
|
||||||
|
|
||||||
function getMeterClass(pct: number) {
|
|
||||||
const threshold = getMeterState(pct)
|
|
||||||
return cn(
|
|
||||||
"h-full",
|
|
||||||
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
|
||||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
|
||||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
|
||||||
STATUS_COLORS.down
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link href={getPagePath($router, "system", { id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
|
|
||||||
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
|
||||||
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
|
||||||
{/* Root disk */}
|
|
||||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
|
||||||
{/* Extra disks */}
|
|
||||||
{extraFs.map(([_name, pct], index) => (
|
|
||||||
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<div className="grid gap-0.5">
|
|
||||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums">{t`Root`}</div>
|
|
||||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
|
||||||
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
|
||||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{extraFs.map(([name, pct]) => {
|
|
||||||
return (
|
|
||||||
<div key={name} className="grid gap-0.5">
|
|
||||||
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">{name}</div>
|
|
||||||
<div className="flex gap-2 items-center tabular-nums text-xs">
|
|
||||||
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
|
||||||
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
|
||||||
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RechartsPrimitive from "recharts"
|
import * as RechartsPrimitive from "recharts"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "./separator"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -102,8 +100,6 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
filter?: string
|
filter?: string
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
showTotal?: boolean
|
|
||||||
totalLabel?: React.ReactNode
|
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -125,16 +121,11 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
itemSorter,
|
itemSorter,
|
||||||
contentFormatter: content = undefined,
|
contentFormatter: content = undefined,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
showTotal = false,
|
|
||||||
totalLabel,
|
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
const config = {}
|
const config = {}
|
||||||
const { t } = useLingui()
|
|
||||||
const totalLabelNode = totalLabel ?? t`Total`
|
|
||||||
const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total`
|
|
||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@@ -150,76 +141,6 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
}, [itemSorter, payload])
|
}, [itemSorter, payload])
|
||||||
|
|
||||||
const totalValueDisplay = React.useMemo(() => {
|
|
||||||
if (!showTotal || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalValue = 0
|
|
||||||
let hasNumericValue = false
|
|
||||||
const aggregatedNestedValues: Record<string, number> = {}
|
|
||||||
|
|
||||||
for (const item of payload) {
|
|
||||||
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
|
|
||||||
if (Number.isFinite(numericValue)) {
|
|
||||||
totalValue += numericValue
|
|
||||||
hasNumericValue = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content && item?.payload) {
|
|
||||||
const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
|
|
||||||
const nestedPayload = (item.payload as Record<string, unknown> | undefined)?.[payloadKey]
|
|
||||||
|
|
||||||
if (nestedPayload && typeof nestedPayload === "object") {
|
|
||||||
for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) {
|
|
||||||
if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
|
|
||||||
aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasNumericValue) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalKey = "__total__"
|
|
||||||
const totalItem: any = {
|
|
||||||
value: totalValue,
|
|
||||||
name: totalName,
|
|
||||||
dataKey: totalKey,
|
|
||||||
color,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
const basePayload =
|
|
||||||
payload[0]?.payload && typeof payload[0].payload === "object"
|
|
||||||
? { ...(payload[0].payload as Record<string, unknown>) }
|
|
||||||
: {}
|
|
||||||
totalItem.payload = {
|
|
||||||
...basePayload,
|
|
||||||
[totalKey]: aggregatedNestedValues,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof formatter === "function") {
|
|
||||||
return formatter(
|
|
||||||
totalValue,
|
|
||||||
totalName,
|
|
||||||
totalItem,
|
|
||||||
payload.length,
|
|
||||||
totalItem.payload ?? payload[0]?.payload
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
|
||||||
return content(totalItem, totalKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${totalValue.toLocaleString()}${unit ?? ""}`
|
|
||||||
}, [color, content, formatter, nameKey, payload, showTotal, totalName, unit])
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
return null
|
return null
|
||||||
@@ -321,15 +242,6 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{totalValueDisplay ? (
|
|
||||||
<>
|
|
||||||
<Separator className="mt-0.5" />
|
|
||||||
<div className="flex items-center justify-between gap-2 -mt-0.75 font-medium">
|
|
||||||
<span className="text-muted-foreground ps-3">{totalLabelNode}</span>
|
|
||||||
<span>{totalValueDisplay}</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
||||||
import type { RecordSubscription } from "pocketbase"
|
import type { RecordSubscription } from "pocketbase"
|
||||||
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
|
import { EthernetIcon } from "@/components/ui/icons"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import type { AlertInfo, AlertRecord } from "@/types"
|
import type { AlertInfo, AlertRecord } from "@/types"
|
||||||
import { pb } from "./api"
|
import { pb } from "./api"
|
||||||
@@ -41,12 +41,6 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
||||||
max: 125,
|
max: 125,
|
||||||
},
|
},
|
||||||
GPU: {
|
|
||||||
name: () => t`GPU Usage`,
|
|
||||||
unit: "%",
|
|
||||||
icon: GpuIcon,
|
|
||||||
desc: () => t`Triggers when GPU usage exceeds a threshold`,
|
|
||||||
},
|
|
||||||
Temperature: {
|
Temperature: {
|
||||||
name: () => t`Temperature`,
|
name: () => t`Temperature`,
|
||||||
unit: "°C",
|
unit: "°C",
|
||||||
|
|||||||
@@ -71,26 +71,3 @@ 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
|
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "الإجمالي"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Токените позволяват на агентите да се с
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Токените и пръстовите отпечатъци се използват за удостоверяване на WebSocket връзките към концентратора."
|
msgstr "Токените и пръстовите отпечатъци се използват за удостоверяване на WebSocket връзките към концентратора."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Общо"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Общо получени данни за всеки интерфейс"
|
msgstr "Общо получени данни за всеки интерфейс"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokeny umožňují agentům připojení a registraci. Otisky jsou stabil
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokeny a otisky slouží k ověření připojení WebSocket k uzlu."
|
msgstr "Tokeny a otisky slouží k ověření připojení WebSocket k uzlu."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Celkem"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Nøgler tillader agenter at oprette forbindelse og registrere. Fingeraft
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Nøgler og fingeraftryk bruges til at godkende WebSocket-forbindelser til hubben."
|
msgstr "Nøgler og fingeraftryk bruges til at godkende WebSocket-forbindelser til hubben."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Samlet"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Samlet modtaget data for hver interface"
|
msgstr "Samlet modtaget data for hver interface"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokens ermöglichen es Agents, sich zu verbinden und zu registrieren. Fi
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
|
msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen zum Hub zu authentifizieren."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Gesamt"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
|
msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
|
||||||
|
|||||||
@@ -1194,11 +1194,6 @@ msgstr "Tokens allow agents to connect and register. Fingerprints are stable ide
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgstr "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Total data received for each interface"
|
msgstr "Total data received for each interface"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: es\n"
|
"Language: es\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-11-04 22:13\n"
|
"PO-Revision-Date: 2025-11-01 17:41\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Spanish\n"
|
"Language-Team: Spanish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -123,7 +123,7 @@ msgstr "Agente"
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Alert History"
|
msgid "Alert History"
|
||||||
msgstr "Historial de alertas"
|
msgstr "Historial de Alertas"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
@@ -142,7 +142,7 @@ msgstr "Todos los contenedores"
|
|||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
msgstr "Todos los sistemas"
|
msgstr "Todos los Sistemas"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Are you sure you want to delete {name}?"
|
msgid "Are you sure you want to delete {name}?"
|
||||||
@@ -746,7 +746,7 @@ msgstr "Instrucciones manuales de configuración"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Máx. 1 min"
|
msgstr "Máx 1 min"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -756,11 +756,11 @@ msgstr "Memoria"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Memory Usage"
|
msgid "Memory Usage"
|
||||||
msgstr "Uso de memoria"
|
msgstr "Uso de Memoria"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Uso de memoria de los contenedores Docker"
|
msgstr "Uso de memoria de los contenedores de Docker"
|
||||||
|
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Model"
|
msgid "Model"
|
||||||
@@ -779,7 +779,7 @@ msgstr "Red"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Network traffic of docker containers"
|
msgid "Network traffic of docker containers"
|
||||||
msgstr "Tráfico de red de los contenedores Docker"
|
msgstr "Tráfico de red de los contenedores de Docker"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
@@ -897,7 +897,7 @@ msgstr "Pausado ({pausedSystemsLength})"
|
|||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Uso promedio por núcleo"
|
msgstr "Utilización promedio por núcleo"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
@@ -905,36 +905,36 @@ msgstr "Porcentaje de tiempo dedicado a cada estado"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
msgstr "Por favor, <0>configura un servidor SMTP</0> para asegurar que las alertas sean entregadas."
|
msgstr "Por favor, <0>configure un servidor SMTP</0> para asegurar que las alertas sean entregadas."
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Please check logs for more details."
|
msgid "Please check logs for more details."
|
||||||
msgstr "Por favor, revisa los registros para más detalles."
|
msgstr "Por favor, revise los registros para más detalles."
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Please check your credentials and try again"
|
msgid "Please check your credentials and try again"
|
||||||
msgstr "Por favor, verifica tus credenciales e inténtalo de nuevo"
|
msgstr "Por favor, verifique sus credenciales e intente de nuevo"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Please create an admin account"
|
msgid "Please create an admin account"
|
||||||
msgstr "Por favor, crea una cuenta de administrador"
|
msgstr "Por favor, cree una cuenta de administrador"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Please enable pop-ups for this site"
|
msgid "Please enable pop-ups for this site"
|
||||||
msgstr "Por favor, habilita las ventanas emergentes para este sitio"
|
msgstr "Por favor, habilite las ventanas emergentes para este sitio"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Please log in again"
|
msgid "Please log in again"
|
||||||
msgstr "Por favor, inicia sesión de nuevo"
|
msgstr "Por favor, inicie sesión de nuevo"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Please see <0>the documentation</0> for instructions."
|
msgid "Please see <0>the documentation</0> for instructions."
|
||||||
msgstr "Por favor, consulta <0>la documentación</0> para obtener instrucciones."
|
msgstr "Por favor, consulte <0>la documentación</0> para obtener instrucciones."
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Please sign in to your account"
|
msgid "Please sign in to your account"
|
||||||
msgstr "Por favor, inicia sesión en tu cuenta"
|
msgstr "Por favor, inicie sesión en su cuenta"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
@@ -952,12 +952,12 @@ msgstr "Utilización precisa en el momento registrado"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Preferred Language"
|
msgid "Preferred Language"
|
||||||
msgstr "Idioma preferido"
|
msgstr "Idioma Preferido"
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
#. Use 'Key' if your language requires many more characters
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Clave pública"
|
msgstr "Clave Pública"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -984,7 +984,7 @@ msgstr "Solicitar OTP"
|
|||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Restablecer contraseña"
|
msgstr "Restablecer Contraseña"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
@@ -1014,7 +1014,7 @@ msgstr "Autoprueba S.M.A.R.T."
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Guarda la dirección usando la tecla enter o coma. Deja en blanco para desactivar las notificaciones por correo."
|
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
@@ -1035,7 +1035,7 @@ msgstr "Buscar sistemas o configuraciones..."
|
|||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Consulta <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
|
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
@@ -1090,7 +1090,7 @@ msgstr "Espacio de swap utilizado por el sistema"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Swap Usage"
|
msgid "Swap Usage"
|
||||||
msgstr "Uso de swap"
|
msgstr "Uso de Swap"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
@@ -1110,7 +1110,7 @@ msgstr "Sistemas"
|
|||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
|
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
|
||||||
msgstr "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dentro de tu directorio de datos."
|
msgstr "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dentro de su directorio de datos."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Table"
|
msgid "Table"
|
||||||
@@ -1145,7 +1145,7 @@ msgstr "Notificación de prueba enviada"
|
|||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Then log into the backend and reset your user account password in the users table."
|
msgid "Then log into the backend and reset your user account password in the users table."
|
||||||
msgstr "Luego inicia sesión en el backend y restablece la contraseña de tu cuenta de usuario en la tabla de usuarios."
|
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||||
@@ -1189,7 +1189,7 @@ msgstr "Token"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens & Fingerprints"
|
msgid "Tokens & Fingerprints"
|
||||||
msgstr "Tokens y huellas digitales"
|
msgstr "Tokens y Huellas Digitales"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
||||||
@@ -1199,11 +1199,6 @@ msgstr "Los tokens permiten que los agentes se conecten y registren. Las huellas
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
|
msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conexiones WebSocket al hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Datos totales recibidos por cada interfaz"
|
msgstr "Datos totales recibidos por cada interfaz"
|
||||||
@@ -1326,7 +1321,7 @@ msgstr "Ver más"
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "View your 200 most recent alerts."
|
msgid "View your 200 most recent alerts."
|
||||||
msgstr "Ver tus 200 alertas más recientes."
|
msgstr "Ver sus 200 alertas más recientes."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
@@ -1338,7 +1333,7 @@ msgstr "Esperando suficientes registros para mostrar"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "¿Quieres ayudar a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
|
msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Warning (%)"
|
msgid "Warning (%)"
|
||||||
@@ -1378,4 +1373,4 @@ msgstr "Configuración YAML"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Tu configuración de usuario ha sido actualizada."
|
msgstr "Su configuración de usuario ha sido actualizada."
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "توکنها به عاملها اجازه اتصال و ثبت
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "توکنها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده میشوند."
|
msgstr "توکنها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده میشوند."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "کل"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "دادههای کل دریافت شده برای هر رابط"
|
msgstr "دادههای کل دریافت شده برای هر رابط"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: fr\n"
|
"Language: fr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-11-11 19:25\n"
|
"PO-Revision-Date: 2025-10-28 22:59\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: French\n"
|
"Language-Team: French\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
@@ -990,7 +990,7 @@ msgstr "Réinitialiser le mot de passe"
|
|||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Resolved"
|
msgid "Resolved"
|
||||||
msgstr "Résolu"
|
msgstr "Résolution"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
@@ -1199,11 +1199,6 @@ msgstr "Les tokens permettent aux agents de se connecter et de s'enregistrer. Le
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connexions WebSocket vers le hub."
|
msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connexions WebSocket vers le hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Données totales reçues pour chaque interface"
|
msgstr "Données totales reçues pour chaque interface"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokens מאפשרים לסוכנים להתחבר ולהירשם. טבי
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens וטביעות אצבע משמשים לאימות חיבורי WebSocket ל-hub."
|
msgstr "Tokens וטביעות אצבע משמשים לאימות חיבורי WebSocket ל-hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "כולל"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "סך נתונים שהתקבלו עבור כל ממשק"
|
msgstr "סך נתונים שהתקבלו עבור כל ממשק"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokeni dopuštaju agentima prijavu i registraciju. Otisci su stabilni id
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokeni se uz otiske koriste za autentifikaciju WebSocket veza prema središnjoj kontroli."
|
msgstr "Tokeni se uz otiske koriste za autentifikaciju WebSocket veza prema središnjoj kontroli."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Ukupno"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Ukupni podaci primljeni za svako sučelje"
|
msgstr "Ukupni podaci primljeni za svako sučelje"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr ""
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Összesen"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Összes fogadott adat minden interfészenként"
|
msgstr "Összes fogadott adat minden interfészenként"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "I token consentono agli agenti di connettersi e registrarsi. Le impronte
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "I token e le impronte digitali vengono utilizzati per autenticare le connessioni WebSocket all'hub."
|
msgstr "I token e le impronte digitali vengono utilizzati per autenticare le connessioni WebSocket all'hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Totale"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Dati totali ricevuti per ogni interfaccia"
|
msgstr "Dati totali ricevuti per ogni interfaccia"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "トークンはエージェントの接続と登録を可能にします
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。"
|
msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。"
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "総数"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "各インターフェースの総受信データ量"
|
msgstr "各インターフェースの総受信データ量"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "토큰은 에이전트가 연결하고 등록할 수 있도록 합니다
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "토큰과 지문은 허브에 대한 WebSocket 연결을 인증하는 데 사용됩니다."
|
msgstr "토큰과 지문은 허브에 대한 WebSocket 연결을 인증하는 데 사용됩니다."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "총"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "각 인터페이스별 총합 다운로드 데이터량"
|
msgstr "각 인터페이스별 총합 다운로드 데이터량"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokens staan agenten toe om verbinding te maken met en te registreren. V
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens en vingerafdrukken worden gebruikt om WebSocket verbindingen te verifiëren naar de hub."
|
msgstr "Tokens en vingerafdrukken worden gebruikt om WebSocket verbindingen te verifiëren naar de hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Totaal"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Totaal ontvangen gegevens per interface"
|
msgstr "Totaal ontvangen gegevens per interface"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokens lar agenter koble til og registrere seg selv. Fingeravtrykk er st
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens og fingeravtrykk blir brukt for å autentisere WebSocket-tilkoblinger til huben."
|
msgstr "Tokens og fingeravtrykk blir brukt for å autentisere WebSocket-tilkoblinger til huben."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Totalt mottatt data for hvert grensesnitt"
|
msgstr "Totalt mottatt data for hvert grensesnitt"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokeny umożliwiają agentom łączenie się i rejestrację. Odciski pal
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokeny i odciski palców (fingerprinty) służą do uwierzytelniania połączeń WebSocket z hubem."
|
msgstr "Tokeny i odciski palców (fingerprinty) służą do uwierzytelniania połączeń WebSocket z hubem."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Łącznie"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Całkowita ilość danych odebranych dla każdego interfejsu"
|
msgstr "Całkowita ilość danych odebranych dla każdego interfejsu"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: pt\n"
|
"Language: pt\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-11-04 22:13\n"
|
"PO-Revision-Date: 2025-10-30 21:52\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Portuguese\n"
|
"Language-Team: Portuguese\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -134,7 +134,7 @@ msgstr "Alertas"
|
|||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/containers.tsx
|
#: src/components/routes/containers.tsx
|
||||||
msgid "All Containers"
|
msgid "All Containers"
|
||||||
msgstr "Todos os Contêineres"
|
msgstr "Todos os contentores"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
@@ -372,11 +372,11 @@ msgstr "CPU"
|
|||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
msgstr "Núcleos de CPU"
|
msgstr "Núcleos da CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Time Breakdown"
|
msgid "CPU Time Breakdown"
|
||||||
msgstr "Distribuição do Tempo de CPU"
|
msgstr "Detalhamento do tempo da CPU"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -606,7 +606,7 @@ msgstr "Impressão digital"
|
|||||||
|
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Firmware"
|
msgid "Firmware"
|
||||||
msgstr "Firmware"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -764,7 +764,7 @@ msgstr "Uso de memória dos contêineres Docker"
|
|||||||
|
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Model"
|
msgid "Model"
|
||||||
msgstr "Modelo"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
@@ -1199,11 +1199,6 @@ msgstr "Os tokens permitem que os agentes se conectem e registrem. As impressõe
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens e impressões digitais são usados para autenticar conexões WebSocket ao hub."
|
msgstr "Tokens e impressões digitais são usados para autenticar conexões WebSocket ao hub."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Dados totais recebidos para cada interface"
|
msgstr "Dados totais recebidos para cada interface"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Токены позволяют агентам подключаться
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Токены и отпечатки используются для аутентификации соединений WebSocket с хабом."
|
msgstr "Токены и отпечатки используются для аутентификации соединений WebSocket с хабом."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Итого"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Общий объем полученных данных для каждого интерфейса"
|
msgstr "Общий объем полученных данных для каждого интерфейса"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Žetoni omogočajo agentom povezavo in registracijo. Prstni odtisi so st
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Žetoni in prstni odtisi se uporabljajo za preverjanje pristnosti WebSocket povezav do vozlišča."
|
msgstr "Žetoni in prstni odtisi se uporabljajo za preverjanje pristnosti WebSocket povezav do vozlišča."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Skupaj"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Skupni prejeti podatki za vsak vmesnik"
|
msgstr "Skupni prejeti podatki za vsak vmesnik"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: sv\n"
|
"Language: sv\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-11-10 01:57\n"
|
"PO-Revision-Date: 2025-10-28 23:00\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Swedish\n"
|
"Language-Team: Swedish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -113,11 +113,11 @@ msgstr "Justera visningsalternativ för diagram."
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Agent"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -448,7 +448,7 @@ msgstr "Urladdar"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disk"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
@@ -625,7 +625,7 @@ msgstr "FreeBSD kommando"
|
|||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Full"
|
msgid "Full"
|
||||||
msgstr "Full"
|
msgstr ""
|
||||||
|
|
||||||
#. Context: General settings
|
#. Context: General settings
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
@@ -746,7 +746,7 @@ msgstr "Manuella installationsinstruktioner"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max 1 min"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -938,7 +938,7 @@ msgstr "Vänligen logga in på ditt konto"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Port"
|
msgstr ""
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1183,7 +1183,7 @@ msgstr "Växla tema"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr "Nyckel"
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1199,11 +1199,6 @@ msgstr "Tokens tillåter agenter att ansluta och registrera. Fingeravtryck är s
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokens och fingeravtryck används för att autentisera WebSocket-anslutningar till hubben."
|
msgstr "Tokens och fingeravtryck används för att autentisera WebSocket-anslutningar till hubben."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Total"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Totalt mottagen data för varje gränssnitt"
|
msgstr "Totalt mottagen data för varje gränssnitt"
|
||||||
@@ -1260,7 +1255,7 @@ msgstr "Enhetsinställningar"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr "Universell nyckel"
|
msgstr "Universal token"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Token'lar agentların bağlanıp kaydolmasına izin verir. Parmak izleri
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Token'lar ve parmak izleri hub'a WebSocket bağlantılarını doğrulamak için kullanılır."
|
msgstr "Token'lar ve parmak izleri hub'a WebSocket bağlantılarını doğrulamak için kullanılır."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Toplam"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Her arayüz için alınan toplam veri"
|
msgstr "Her arayüz için alınan toplam veri"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Токени дозволяють агентам підключатис
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу."
|
msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Разом"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Загальний обсяг отриманих даних для кожного інтерфейсу"
|
msgstr "Загальний обсяг отриманих даних для кожного інтерфейсу"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "Token cho phép các tác nhân kết nối và đăng ký. Vân tay là
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Token và vân tay được sử dụng để xác thực các kết nối WebSocket đến trung tâm."
|
msgstr "Token và vân tay được sử dụng để xác thực các kết nối WebSocket đến trung tâm."
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "Tổng"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Tổng dữ liệu nhận được cho mỗi giao diện"
|
msgstr "Tổng dữ liệu nhận được cho mỗi giao diện"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "令牌允许客户端连接和注册。指纹是每个系统唯一的稳
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "令牌与指纹用于验证到中心的 WebSocket 连接。"
|
msgstr "令牌与指纹用于验证到中心的 WebSocket 连接。"
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "总计"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "每个接口的总接收数据量"
|
msgstr "每个接口的总接收数据量"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
|
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "總計"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "每個介面的總接收資料量"
|
msgstr "每個介面的總接收資料量"
|
||||||
|
|||||||
@@ -1199,11 +1199,6 @@ msgstr "令牌允許代理程式連線和註冊。指紋是每個系統的唯一
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "令牌和指紋被用於驗證到 Hub 的 WebSocket 連線。"
|
msgstr "令牌和指紋被用於驗證到 Hub 的 WebSocket 連線。"
|
||||||
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
#: src/components/ui/chart.tsx
|
|
||||||
msgid "Total"
|
|
||||||
msgstr "總計"
|
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "每個介面的總接收資料量"
|
msgstr "每個介面的總接收資料量"
|
||||||
|
|||||||
163
internal/site/src/types.d.ts
vendored
163
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, ServiceStatus, ServiceSubState } from "@/lib/enums"
|
import type { Unit, Os, BatteryState, HourFormat, ConnectionType } from "@/lib/enums"
|
||||||
|
|
||||||
// global window properties
|
// global window properties
|
||||||
declare global {
|
declare global {
|
||||||
@@ -77,8 +77,6 @@ export interface SystemInfo {
|
|||||||
os?: Os
|
os?: Os
|
||||||
/** connection type */
|
/** connection type */
|
||||||
ct?: ConnectionType
|
ct?: ConnectionType
|
||||||
/** extra filesystem percentages */
|
|
||||||
efs?: Record<string, number>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
@@ -86,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
|
||||||
@@ -303,18 +301,18 @@ export interface ChartData {
|
|||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlertInfo {
|
// interface AlertInfo {
|
||||||
name: () => string
|
// name: () => string
|
||||||
unit: string
|
// unit: string
|
||||||
icon: any
|
// icon: any
|
||||||
desc: () => string
|
// desc: () => string
|
||||||
max?: number
|
// max?: number
|
||||||
min?: number
|
// min?: number
|
||||||
step?: number
|
// step?: number
|
||||||
start?: number
|
// start?: number
|
||||||
/** Single value description (when there's only one value, like status) */
|
// /** Single value description (when there's only one value, like status) */
|
||||||
singleDesc?: () => string
|
// singleDesc?: () => string
|
||||||
}
|
// }
|
||||||
|
|
||||||
export type AlertMap = Record<string, Map<string, AlertRecord>>
|
export type AlertMap = Record<string, Map<string, AlertRecord>>
|
||||||
|
|
||||||
@@ -358,131 +356,4 @@ 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[];
|
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
## 0.15.4
|
|
||||||
|
|
||||||
- Refactor containers table to fix clock issue causing no results. (#1337)
|
|
||||||
|
|
||||||
- Fix Windows extra disk detection. (#1361)
|
|
||||||
|
|
||||||
- Add total line to the tooltip of charts with multiple values. (#1280)
|
|
||||||
|
|
||||||
- Add fallback paths for `smartctl` lookup. (#1362, #1363)
|
|
||||||
|
|
||||||
- Fix `intel_gpu_top` parsing when engine instance id is in column. (#1230)
|
|
||||||
|
|
||||||
- Update `henrygd/beszel-agent-nvidia` Dockerfile to build latest smartmontools. (#1335)
|
|
||||||
|
|
||||||
## 0.15.3
|
## 0.15.3
|
||||||
|
|
||||||
- Add CPU state details and per-core usage. (#1356)
|
- Add CPU state details and per-core usage. (#1356)
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 2, June 1991
|
|
||||||
|
|
||||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
|
||||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The licenses for most software are designed to take away your
|
|
||||||
freedom to share and change it. By contrast, the GNU General Public
|
|
||||||
License is intended to guarantee your freedom to share and change free
|
|
||||||
software--to make sure the software is free for all its users. This
|
|
||||||
General Public License applies to most of the Free Software
|
|
||||||
Foundation's software and to any other program whose authors commit to
|
|
||||||
using it. (Some other Free Software Foundation software is covered by
|
|
||||||
the GNU Lesser General Public License instead.) You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
this service if you wish), that you receive source code or can get it
|
|
||||||
if you want it, that you can change the software or use pieces of it
|
|
||||||
in new free programs; and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to make restrictions that forbid
|
|
||||||
anyone to deny you these rights or to ask you to surrender the rights.
|
|
||||||
These restrictions translate to certain responsibilities for you if you
|
|
||||||
distribute copies of the software, or if you modify it.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must give the recipients all the rights that
|
|
||||||
you have. You must make sure that they, too, receive or can get the
|
|
||||||
source code. And you must show them these terms so they know their
|
|
||||||
rights.
|
|
||||||
|
|
||||||
We protect your rights with two steps: (1) copyright the software, and
|
|
||||||
(2) offer you this license which gives you legal permission to copy,
|
|
||||||
distribute and/or modify the software.
|
|
||||||
|
|
||||||
Also, for each author's protection and ours, we want to make certain
|
|
||||||
that everyone understands that there is no warranty for this free
|
|
||||||
software. If the software is modified by someone else and passed on, we
|
|
||||||
want its recipients to know that what they have is not the original, so
|
|
||||||
that any problems introduced by others will not reflect on the original
|
|
||||||
authors' reputations.
|
|
||||||
|
|
||||||
Finally, any free program is threatened constantly by software
|
|
||||||
patents. We wish to avoid the danger that redistributors of a free
|
|
||||||
program will individually obtain patent licenses, in effect making the
|
|
||||||
program proprietary. To prevent this, we have made it clear that any
|
|
||||||
patent must be licensed for everyone's free use or not licensed at all.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
||||||
|
|
||||||
0. This License applies to any program or other work which contains
|
|
||||||
a notice placed by the copyright holder saying it may be distributed
|
|
||||||
under the terms of this General Public License. The "Program", below,
|
|
||||||
refers to any such program or work, and a "work based on the Program"
|
|
||||||
means either the Program or any derivative work under copyright law:
|
|
||||||
that is to say, a work containing the Program or a portion of it,
|
|
||||||
either verbatim or with modifications and/or translated into another
|
|
||||||
language. (Hereinafter, translation is included without limitation in
|
|
||||||
the term "modification".) Each licensee is addressed as "you".
|
|
||||||
|
|
||||||
Activities other than copying, distribution and modification are not
|
|
||||||
covered by this License; they are outside its scope. The act of
|
|
||||||
running the Program is not restricted, and the output from the Program
|
|
||||||
is covered only if its contents constitute a work based on the
|
|
||||||
Program (independent of having been made by running the Program).
|
|
||||||
Whether that is true depends on what the Program does.
|
|
||||||
|
|
||||||
1. You may copy and distribute verbatim copies of the Program's
|
|
||||||
source code as you receive it, in any medium, provided that you
|
|
||||||
conspicuously and appropriately publish on each copy an appropriate
|
|
||||||
copyright notice and disclaimer of warranty; keep intact all the
|
|
||||||
notices that refer to this License and to the absence of any warranty;
|
|
||||||
and give any other recipients of the Program a copy of this License
|
|
||||||
along with the Program.
|
|
||||||
|
|
||||||
You may charge a fee for the physical act of transferring a copy, and
|
|
||||||
you may at your option offer warranty protection in exchange for a fee.
|
|
||||||
|
|
||||||
2. You may modify your copy or copies of the Program or any portion
|
|
||||||
of it, thus forming a work based on the Program, and copy and
|
|
||||||
distribute such modifications or work under the terms of Section 1
|
|
||||||
above, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) You must cause the modified files to carry prominent notices
|
|
||||||
stating that you changed the files and the date of any change.
|
|
||||||
|
|
||||||
b) You must cause any work that you distribute or publish, that in
|
|
||||||
whole or in part contains or is derived from the Program or any
|
|
||||||
part thereof, to be licensed as a whole at no charge to all third
|
|
||||||
parties under the terms of this License.
|
|
||||||
|
|
||||||
c) If the modified program normally reads commands interactively
|
|
||||||
when run, you must cause it, when started running for such
|
|
||||||
interactive use in the most ordinary way, to print or display an
|
|
||||||
announcement including an appropriate copyright notice and a
|
|
||||||
notice that there is no warranty (or else, saying that you provide
|
|
||||||
a warranty) and that users may redistribute the program under
|
|
||||||
these conditions, and telling the user how to view a copy of this
|
|
||||||
License. (Exception: if the Program itself is interactive but
|
|
||||||
does not normally print such an announcement, your work based on
|
|
||||||
the Program is not required to print an announcement.)
|
|
||||||
|
|
||||||
These requirements apply to the modified work as a whole. If
|
|
||||||
identifiable sections of that work are not derived from the Program,
|
|
||||||
and can be reasonably considered independent and separate works in
|
|
||||||
themselves, then this License, and its terms, do not apply to those
|
|
||||||
sections when you distribute them as separate works. But when you
|
|
||||||
distribute the same sections as part of a whole which is a work based
|
|
||||||
on the Program, the distribution of the whole must be on the terms of
|
|
||||||
this License, whose permissions for other licensees extend to the
|
|
||||||
entire whole, and thus to each and every part regardless of who wrote it.
|
|
||||||
|
|
||||||
Thus, it is not the intent of this section to claim rights or contest
|
|
||||||
your rights to work written entirely by you; rather, the intent is to
|
|
||||||
exercise the right to control the distribution of derivative or
|
|
||||||
collective works based on the Program.
|
|
||||||
|
|
||||||
In addition, mere aggregation of another work not based on the Program
|
|
||||||
with the Program (or with a work based on the Program) on a volume of
|
|
||||||
a storage or distribution medium does not bring the other work under
|
|
||||||
the scope of this License.
|
|
||||||
|
|
||||||
3. You may copy and distribute the Program (or a work based on it,
|
|
||||||
under Section 2) in object code or executable form under the terms of
|
|
||||||
Sections 1 and 2 above provided that you also do one of the following:
|
|
||||||
|
|
||||||
a) Accompany it with the complete corresponding machine-readable
|
|
||||||
source code, which must be distributed under the terms of Sections
|
|
||||||
1 and 2 above on a medium customarily used for software interchange; or,
|
|
||||||
|
|
||||||
b) Accompany it with a written offer, valid for at least three
|
|
||||||
years, to give any third party, for a charge no more than your
|
|
||||||
cost of physically performing source distribution, a complete
|
|
||||||
machine-readable copy of the corresponding source code, to be
|
|
||||||
distributed under the terms of Sections 1 and 2 above on a medium
|
|
||||||
customarily used for software interchange; or,
|
|
||||||
|
|
||||||
c) Accompany it with the information you received as to the offer
|
|
||||||
to distribute corresponding source code. (This alternative is
|
|
||||||
allowed only for noncommercial distribution and only if you
|
|
||||||
received the program in object code or executable form with such
|
|
||||||
an offer, in accord with Subsection b above.)
|
|
||||||
|
|
||||||
The source code for a work means the preferred form of the work for
|
|
||||||
making modifications to it. For an executable work, complete source
|
|
||||||
code means all the source code for all modules it contains, plus any
|
|
||||||
associated interface definition files, plus the scripts used to
|
|
||||||
control compilation and installation of the executable. However, as a
|
|
||||||
special exception, the source code distributed need not include
|
|
||||||
anything that is normally distributed (in either source or binary
|
|
||||||
form) with the major components (compiler, kernel, and so on) of the
|
|
||||||
operating system on which the executable runs, unless that component
|
|
||||||
itself accompanies the executable.
|
|
||||||
|
|
||||||
If distribution of executable or object code is made by offering
|
|
||||||
access to copy from a designated place, then offering equivalent
|
|
||||||
access to copy the source code from the same place counts as
|
|
||||||
distribution of the source code, even though third parties are not
|
|
||||||
compelled to copy the source along with the object code.
|
|
||||||
|
|
||||||
4. You may not copy, modify, sublicense, or distribute the Program
|
|
||||||
except as expressly provided under this License. Any attempt
|
|
||||||
otherwise to copy, modify, sublicense or distribute the Program is
|
|
||||||
void, and will automatically terminate your rights under this License.
|
|
||||||
However, parties who have received copies, or rights, from you under
|
|
||||||
this License will not have their licenses terminated so long as such
|
|
||||||
parties remain in full compliance.
|
|
||||||
|
|
||||||
5. You are not required to accept this License, since you have not
|
|
||||||
signed it. However, nothing else grants you permission to modify or
|
|
||||||
distribute the Program or its derivative works. These actions are
|
|
||||||
prohibited by law if you do not accept this License. Therefore, by
|
|
||||||
modifying or distributing the Program (or any work based on the
|
|
||||||
Program), you indicate your acceptance of this License to do so, and
|
|
||||||
all its terms and conditions for copying, distributing or modifying
|
|
||||||
the Program or works based on it.
|
|
||||||
|
|
||||||
6. Each time you redistribute the Program (or any work based on the
|
|
||||||
Program), the recipient automatically receives a license from the
|
|
||||||
original licensor to copy, distribute or modify the Program subject to
|
|
||||||
these terms and conditions. You may not impose any further
|
|
||||||
restrictions on the recipients' exercise of the rights granted herein.
|
|
||||||
You are not responsible for enforcing compliance by third parties to
|
|
||||||
this License.
|
|
||||||
|
|
||||||
7. If, as a consequence of a court judgment or allegation of patent
|
|
||||||
infringement or for any other reason (not limited to patent issues),
|
|
||||||
conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot
|
|
||||||
distribute so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you
|
|
||||||
may not distribute the Program at all. For example, if a patent
|
|
||||||
license would not permit royalty-free redistribution of the Program by
|
|
||||||
all those who receive copies directly or indirectly through you, then
|
|
||||||
the only way you could satisfy both it and this License would be to
|
|
||||||
refrain entirely from distribution of the Program.
|
|
||||||
|
|
||||||
If any portion of this section is held invalid or unenforceable under
|
|
||||||
any particular circumstance, the balance of the section is intended to
|
|
||||||
apply and the section as a whole is intended to apply in other
|
|
||||||
circumstances.
|
|
||||||
|
|
||||||
It is not the purpose of this section to induce you to infringe any
|
|
||||||
patents or other property right claims or to contest validity of any
|
|
||||||
such claims; this section has the sole purpose of protecting the
|
|
||||||
integrity of the free software distribution system, which is
|
|
||||||
implemented by public license practices. Many people have made
|
|
||||||
generous contributions to the wide range of software distributed
|
|
||||||
through that system in reliance on consistent application of that
|
|
||||||
system; it is up to the author/donor to decide if he or she is willing
|
|
||||||
to distribute software through any other system and a licensee cannot
|
|
||||||
impose that choice.
|
|
||||||
|
|
||||||
This section is intended to make thoroughly clear what is believed to
|
|
||||||
be a consequence of the rest of this License.
|
|
||||||
|
|
||||||
8. If the distribution and/or use of the Program is restricted in
|
|
||||||
certain countries either by patents or by copyrighted interfaces, the
|
|
||||||
original copyright holder who places the Program under this License
|
|
||||||
may add an explicit geographical distribution limitation excluding
|
|
||||||
those countries, so that distribution is permitted only in or among
|
|
||||||
countries not thus excluded. In such case, this License incorporates
|
|
||||||
the limitation as if written in the body of this License.
|
|
||||||
|
|
||||||
9. The Free Software Foundation may publish revised and/or new versions
|
|
||||||
of the General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the Program
|
|
||||||
specifies a version number of this License which applies to it and "any
|
|
||||||
later version", you have the option of following the terms and conditions
|
|
||||||
either of that version or of any later version published by the Free
|
|
||||||
Software Foundation. If the Program does not specify a version number of
|
|
||||||
this License, you may choose any version ever published by the Free Software
|
|
||||||
Foundation.
|
|
||||||
|
|
||||||
10. If you wish to incorporate parts of the Program into other free
|
|
||||||
programs whose distribution conditions are different, write to the author
|
|
||||||
to ask for permission. For software which is copyrighted by the Free
|
|
||||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
|
||||||
make exceptions for this. Our decision will be guided by the two goals
|
|
||||||
of preserving the free status of all derivatives of our free software and
|
|
||||||
of promoting the sharing and reuse of software generally.
|
|
||||||
|
|
||||||
NO WARRANTY
|
|
||||||
|
|
||||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
|
||||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
|
||||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
|
||||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
|
||||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
|
||||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
|
||||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
|
||||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
|
||||||
REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
|
||||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
|
||||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
|
||||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
|
||||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
|
||||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
|
||||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
|
||||||
POSSIBILITY OF SUCH DAMAGES.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
convey the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation; either version 2 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along
|
|
||||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
|
||||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program is interactive, make it output a short notice like this
|
|
||||||
when it starts in an interactive mode:
|
|
||||||
|
|
||||||
Gnomovision version 69, Copyright (C) year name of author
|
|
||||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, the commands you use may
|
|
||||||
be called something other than `show w' and `show c'; they could even be
|
|
||||||
mouse-clicks or menu items--whatever suits your program.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or your
|
|
||||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
|
||||||
necessary. Here is a sample; alter the names:
|
|
||||||
|
|
||||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
|
||||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
|
||||||
|
|
||||||
<signature of Ty Coon>, 1 April 1989
|
|
||||||
Ty Coon, President of Vice
|
|
||||||
|
|
||||||
This General Public License does not permit incorporating your program into
|
|
||||||
proprietary programs. If your program is a subroutine library, you may
|
|
||||||
consider it more useful to permit linking proprietary applications with the
|
|
||||||
library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License.
|
|
||||||
Reference in New Issue
Block a user