Compare commits

..

29 Commits

Author SHA1 Message Date
Sven van Ginkel
de1ad26fc2 Add extra disks to system table (#1365) 2025-11-12 14:17:07 -05:00
henrygd
a7ef1235f4 specify latest tag for non-alpine agent image
also change capitalization for gpu alert
2025-11-11 16:18:54 -05:00
henrygd
f64a361c60 add EXCLUDE_SMART env var (#1392) 2025-11-11 16:05:00 -05:00
henrygd
aaa788bc2f add gpu usage alerts 2025-11-11 12:38:47 -05:00
henrygd
3eede6bead fix containers and systemd tables when using system name in URL 2025-11-10 17:43:11 -05:00
henrygd
02abfbcb54 change alert link to use system ID instead of name 2025-11-10 17:31:11 -05:00
henrygd
01d20562f0 add basic systemd service monitoring (#1153)
Co-authored-by: Shelby Tucker <shelby.tucker@gmail.com>
2025-11-10 17:04:51 -05:00
henrygd
cbe6ee6499 fix battery nil pointer error (#1353) 2025-11-10 15:16:50 -05:00
henrygd
9a61ea8356 improve perf of filter bar 2025-11-07 22:29:59 -05:00
henrygd
1af7ff600f embed smartctl in the windows binary (#1362) 2025-11-05 13:03:47 -05:00
henrygd
02d594cc82 release 0.15.4 2025-11-04 17:23:42 -05:00
henrygd
7d0b5c1c67 update language files 2025-11-04 17:18:57 -05:00
Thiago Alves Cavalcante
d3dc8a7af0 new Portuguese translations 2025-11-04 17:18:07 -05:00
henrygd
d67fefe7c5 new spanish translations by dtornerte 2025-11-04 17:17:02 -05:00
henrygd
4d364c5e4d update language files 2025-11-04 17:06:51 -05:00
henrygd
954400ea45 fix intel_gpu_top parsing when engine instance id is in column (#1230) 2025-11-04 16:02:20 -05:00
henrygd
04b6067e64 add a total line to the tooltip of charts with multiple values #1280
Co-authored-by: Titouan V <titouan.verhille@gmail.com>
2025-11-04 15:41:24 -05:00
henrygd
d77ee5554f add fallback paths for smartctl lookup (#1362, #1363) 2025-11-04 14:06:28 -05:00
henrygd
2e034bdead refactor containers table to fix clock issue causing no results (#1337) 2025-11-04 13:18:34 -05:00
henrygd
fc0947aa04 fix windows extra disk backslash issue (#1361) 2025-11-03 17:42:08 -05:00
henrygd
1d546a4091 update nvidia dockerfile to build latest smartmontools (#1335) 2025-11-02 17:13:47 -05:00
henrygd
f60b3bbbfb release 0.15.3 2025-11-01 16:04:02 -04:00
henrygd
8e99b9f1ad update shoutrrr and gopsutil deps 2025-11-01 14:31:41 -04:00
henrygd
fa5ed2bc11 update translations 2025-11-01 14:09:10 -04:00
henrygd
21d961ab97 sync language files 2025-11-01 13:50:53 -04:00
henrygd
aaa93b84d2 add hebrew + new cpu charts refactoring 2025-11-01 13:34:30 -04:00
henrygd
6a562ce03b add more cpu metrics (#1356)
- adds monitoring for cpu state time and per-core usage

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
2025-11-01 12:57:58 -04:00
henrygd
3dbc48727e add INTEL_GPU_DEVICE env var (#1285) 2025-11-01 11:12:05 -04:00
henrygd
85ac2e5e9a update env var name to EXCLUDE_CONTAINERS #1352 2025-10-30 19:30:01 -04:00
98 changed files with 6478 additions and 552 deletions

View File

@@ -33,6 +33,7 @@ jobs:
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -99,6 +100,7 @@ jobs:
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

View File

@@ -5,6 +5,7 @@ project_name: beszel
before:
hooks:
- go mod tidy
- go generate -run fetchsmartctl ./agent
builds:
- id: beszel

View File

@@ -7,7 +7,7 @@ SKIP_WEB ?= false
# Set executable extension based on target OS
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
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
.DEFAULT_GOAL := build
clean:
@@ -46,8 +46,14 @@ build-dotnet-conditional:
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
build-agent: tidy build-dotnet-conditional
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
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)

View File

@@ -43,6 +43,7 @@ type Agent struct {
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -101,6 +102,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
agent.systemdManager, err = newSystemdManager()
if err != nil {
slog.Debug("Systemd", "err", err)
}
agent.smartManager, err = NewSmartManager()
if err != nil {
slog.Debug("SMART", "err", err)
@@ -154,7 +160,13 @@ 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.Info.ExtraFsPct = make(map[string]system.ExtraFsInfo)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
// Use custom name if available, otherwise use device name
@@ -163,6 +175,11 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
key = stats.Name
}
data.Stats.ExtraFs[key] = stats
// Add percentage info to Info struct for dashboard
if stats.DiskTotal > 0 {
pct := (stats.DiskUsed / stats.DiskTotal) * 100
data.Info.ExtraFsPct[key] = system.ExtraFsInfo{DiskPct: pct}
}
}
}
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)

View File

@@ -6,6 +6,7 @@ package battery
import (
"errors"
"log/slog"
"math"
"github.com/distatus/battery"
)
@@ -51,21 +52,26 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
totalCharge := float64(0)
errs, partialErrs := err.(battery.Errors)
batteryState = math.MaxUint8
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
continue
}
if bat.Full == 0 {
if bat == nil || bat.Full == 0 {
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
// we can't guarantee that, so don't skip based on charge.
continue
}
totalCapacity += bat.Full
totalCharge += bat.Current
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}
}
if totalCapacity == 0 {
if totalCapacity == 0 || batteryState == math.MaxUint8 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
@@ -74,6 +80,5 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
batteryState = uint8(batteries[0].State.Raw)
return batteryPercent, batteryState, nil
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
@@ -276,6 +277,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
// case []byte:
// response.RawBytes = v
// case string:

View File

@@ -4,10 +4,12 @@ import (
"math"
"runtime"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
)
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
@@ -15,23 +17,92 @@ func init() {
if times, err := cpu.Times(false); err == nil {
lastCpuTimes[60000] = times[0]
}
if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
}
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
// It uses the specified cache time interval to determine the time window for calculation.
// Returns the CPU usage percentage (0-100) and any error encountered.
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
// CpuMetrics contains detailed CPU usage breakdown
type CpuMetrics struct {
Total float64
User float64
System float64
Iowait float64
Steal float64
Idle float64
}
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
times, err := cpu.Times(false)
if err != nil || len(times) == 0 {
return 0, err
return CpuMetrics{}, err
}
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
}
delta := calculateBusy(lastCpuTimes[cacheTimeMs], times[0])
t1 := lastCpuTimes[cacheTimeMs]
t2 := times[0]
t1All, _ := getAllBusy(t1)
t2All, _ := getAllBusy(t2)
totalDelta := t2All - t1All
if totalDelta <= 0 {
return CpuMetrics{}, nil
}
metrics := CpuMetrics{
Total: calculateBusy(t1, t2),
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
}
lastCpuTimes[cacheTimeMs] = times[0]
return delta, nil
return metrics, nil
}
// clampPercent ensures the percentage is between 0 and 100
func clampPercent(value float64) float64 {
return math.Min(100, math.Max(0, value))
}
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
// It uses cached previous measurements for the provided cache interval.
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
perCoreTimes, err := cpu.Times(true)
if err != nil || len(perCoreTimes) == 0 {
return nil, err
}
// Initialize cache if needed
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
}
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
// Limit to the number of cores available in both samples
length := len(perCoreTimes)
if len(lastTimes) < length {
length = len(lastTimes)
}
usage := make([]uint8, length)
for i := 0; i < length; i++ {
t1 := lastTimes[i]
t2 := perCoreTimes[i]
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
}
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
return usage, nil
}
// calculateBusy calculates the CPU busy percentage between two time points.
@@ -41,13 +112,10 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
t1All, t1Busy := getAllBusy(t1)
t2All, t2Busy := getAllBusy(t2)
if t2Busy <= t1Busy {
if t2All <= t1All || t2Busy <= t1Busy {
return 0
}
if t2All <= t1All {
return 100
}
return math.Min(100, math.Max(0, (t2Busy-t1Busy)/(t2All-t1All)*100))
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
}
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.

View File

@@ -31,6 +31,7 @@ func (a *Agent) initializeDiskInfo() {
filesystem, _ := GetEnv("FILESYSTEM")
efPath := "/extra-filesystems"
hasRoot := false
isWindows := runtime.GOOS == "windows"
partitions, err := disk.Partitions(false)
if err != nil {
@@ -38,6 +39,13 @@ func (a *Agent) initializeDiskInfo() {
}
slog.Debug("Disk", "partitions", partitions)
// trim trailing backslash for Windows devices (#1361)
if isWindows {
for i, p := range partitions {
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
}
}
// ioContext := context.WithValue(a.sensorsContext,
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
// )
@@ -52,7 +60,7 @@ func (a *Agent) initializeDiskInfo() {
// Helper function to add a filesystem to fsStats if it doesn't exist
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
var key string
if runtime.GOOS == "windows" {
if isWindows {
key = device
} else {
key = filepath.Base(device)

View File

@@ -54,7 +54,7 @@ type dockerManager struct {
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
containerExclude []string // Patterns to exclude containers by name (supports wildcards)
excludeContainers []string // Patterns to exclude containers by name
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -96,13 +96,12 @@ func (d *dockerManager) dequeue() {
}
}
// shouldExcludeContainer checks if a container name matches any exclusion pattern using path.Match
// shouldExcludeContainer checks if a container name matches any exclusion pattern
func (dm *dockerManager) shouldExcludeContainer(name string) bool {
if len(dm.containerExclude) == 0 {
if len(dm.excludeContainers) == 0 {
return false
}
for _, pattern := range dm.containerExclude {
// Use path.Match for wildcard support
for _, pattern := range dm.excludeContainers {
if match, _ := path.Match(pattern, name); match {
return true
}
@@ -138,15 +137,9 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
for _, ctr := range dm.apiContainerList {
ctr.IdShort = ctr.Id[:12]
// Extract container name and check if it should be excluded
name := ctr.Names[0]
if len(name) > 0 && name[0] == '/' {
name = name[1:]
}
// Skip this container if it matches the exclusion pattern
if dm.shouldExcludeContainer(name) {
slog.Debug("Excluding container", "name", name, "patterns", dm.containerExclude)
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
continue
}
@@ -532,20 +525,17 @@ func newDockerManager(a *Agent) *dockerManager {
userAgent: "Docker-Client/",
}
// Read container exclusion patterns from environment variable (comma-separated, supports wildcards)
var containerExclude []string
if excludeStr, set := GetEnv("CONTAINER_EXCLUDE"); set && excludeStr != "" {
// Split by comma and trim whitespace
parts := strings.Split(excludeStr, ",")
for _, part := range parts {
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
parts := strings.SplitSeq(excludeStr, ",")
for part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
containerExclude = append(containerExclude, trimmed)
excludeContainers = append(excludeContainers, trimmed)
}
}
if len(containerExclude) > 0 {
slog.Info("Container exclusion patterns set", "patterns", containerExclude)
}
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
}
manager := &dockerManager{
@@ -557,7 +547,7 @@ func newDockerManager(a *Agent) *dockerManager {
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
apiStats: &container.ApiStats{},
containerExclude: containerExclude,
excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64),

View File

@@ -1196,7 +1196,7 @@ func TestShouldExcludeContainer(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dm := &dockerManager{
containerExclude: tt.patterns,
excludeContainers: tt.patterns,
}
result := dm.shouldExcludeContainer(tt.containerName)
assert.Equal(t, tt.expected, result)

View File

@@ -49,7 +49,12 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
func (gm *GPUManager) collectIntelStats() (err error) {
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
// Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev)
}
cmd := exec.Command(intelGpuStatsCmd, args...)
// Avoid blocking if intel_gpu_top writes to stderr
cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe()
@@ -129,7 +134,9 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
powerIndex = -1 // Initialize to -1, will be set to actual index if found
// Collect engine names from header1
for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
key := strings.TrimRightFunc(col, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '/'
})
var friendly string
switch key {
case "RCS":

View File

@@ -4,8 +4,10 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -1437,6 +1439,15 @@ func TestParseIntelHeaders(t *testing.T) {
wantPowerIndex: 4, // "gpu" is at index 4
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",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
@@ -1624,3 +1635,42 @@ func TestParseIntelData(t *testing.T) {
})
}
}
func TestIntelCollectorDeviceEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
// Prepare a file to capture args
argsFile := filepath.Join(dir, "args.txt")
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
scriptPath := filepath.Join(dir, "intel_gpu_top")
script := fmt.Sprintf(`#!/bin/sh
echo "$@" > %s
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
`, argsFile)
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
// Set device selector via prefixed env var
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
if err := gm.collectIntelStats(); err != nil {
t.Fatalf("collectIntelStats error: %v", err)
}
// Verify that -d sriov was passed
data, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("failed reading args file: %v", err)
}
argsStr := strings.TrimSpace(string(data))
require.Contains(t, argsStr, "-d sriov")
require.Contains(t, argsStr, "-s ")
require.Contains(t, argsStr, "-l")
}

View File

@@ -50,6 +50,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
return registry
}
@@ -174,3 +175,31 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
data := hctx.Agent.smartManager.GetCurrentData()
return hctx.SendResponse(data, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdInfoHandler handles detailed systemd service info requests
type GetSystemdInfoHandler struct{}
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdInfoRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(details, hctx.RequestID)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
@@ -173,6 +174,8 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
default:
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}

View File

@@ -1,3 +1,6 @@
//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
import (
@@ -5,7 +8,9 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
@@ -19,10 +24,12 @@ import (
// SmartManager manages data collection for SMART devices
type SmartManager struct {
sync.Mutex
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
binPath string
excludedDevices map[string]struct{}
}
type scanOutput struct {
@@ -160,7 +167,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
output, err := cmd.Output()
var (
@@ -179,6 +186,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
}
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices)
if len(finalDevices) == 0 {
@@ -226,6 +234,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
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
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
// when the reported device type is ambiguous or missing.
@@ -372,6 +421,10 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
// for initial data collection when no cached data exists
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))
// Check if we have any existing data for this device
@@ -382,7 +435,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
// Try with -n standby first if we have existing data
args := sm.smartctlArgs(deviceInfo, true)
cmd := exec.CommandContext(ctx, "smartctl", args...)
cmd := exec.CommandContext(ctx, sm.binPath, args...)
output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2)
@@ -395,7 +448,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx2, "smartctl", args...)
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
output, err = cmd.CombinedOutput()
}
@@ -403,10 +456,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
if !hasValidData {
if err != nil {
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
return err
}
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
return errNoValidSmartData
}
@@ -875,13 +928,33 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
}
// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() error {
if _, err := exec.LookPath("smartctl"); err == nil {
slog.Debug("smartctl found")
return nil
func (sm *SmartManager) detectSmartctl() (string, error) {
isWindows := runtime.GOOS == "windows"
// Load embedded smartctl.exe for Windows amd64 builds.
if isWindows && runtime.GOARCH == "amd64" {
if path, err := ensureEmbeddedSmartctl(); err == nil {
return path, nil
}
}
slog.Debug("smartctl not found")
return errors.New("smartctl not found")
if path, err := exec.LookPath("smartctl"); err == nil {
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
@@ -889,9 +962,13 @@ func NewSmartManager() (*SmartManager, error) {
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
if err := sm.detectSmartctl(); err != nil {
sm.refreshExcludedDevices()
path, err := sm.detectSmartctl()
if err != nil {
slog.Debug(err.Error())
return nil, err
}
slog.Debug("smartctl", "path", path)
sm.binPath = path
return sm, nil
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
package agent
import "errors"
func ensureEmbeddedSmartctl() (string, error) {
return "", errors.ErrUnsupported
}

View File

@@ -588,3 +588,195 @@ 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)
})
}
}

40
agent/smart_windows.go Normal file
View File

@@ -0,0 +1,40 @@
//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
}

View File

@@ -83,12 +83,24 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
systemStats.Battery[1] = batteryState
}
// cpu percent
cpuPercent, err := getCpuPercent(cacheTimeMs)
// cpu metrics
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
if err == nil {
systemStats.Cpu = twoDecimals(cpuPercent)
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
systemStats.CpuBreakdown = []float64{
twoDecimals(cpuMetrics.User),
twoDecimals(cpuMetrics.System),
twoDecimals(cpuMetrics.Iowait),
twoDecimals(cpuMetrics.Steal),
twoDecimals(cpuMetrics.Idle),
}
} else {
slog.Error("Error getting cpu percent", "err", err)
slog.Error("Error getting cpu metrics", "err", err)
}
// per-core cpu usage
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
systemStats.CpuCoresUsage = perCoreUsage
}
// load average

229
agent/systemd.go Normal file
View File

@@ -0,0 +1,229 @@
//go:build linux
package agent
import (
"context"
"errors"
"log/slog"
"maps"
"math"
"strconv"
"strings"
"sync"
"time"
"github.com/coreos/go-systemd/v22/dbus"
"github.com/henrygd/beszel/internal/entities/systemd"
)
var (
errNoActiveTime = errors.New("no active time")
)
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
sync.Mutex
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
if err != nil {
slog.Warn("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
return nil, err
}
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
}
manager.startWorker(conn)
return manager, nil
}
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
if sm.isRunning {
return
}
sm.isRunning = true
// prime the service stats map with the current services
_ = sm.getServiceStats(conn, true)
// update the services every 10 minutes
go func() {
for {
time.Sleep(time.Minute * 10)
_ = sm.getServiceStats(nil, true)
}
}()
}
// getServiceStats collects statistics for all running systemd services.
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
// start := time.Now()
// defer func() {
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
// }()
var services []*systemd.Service
var err error
if !refresh {
// return nil
sm.Lock()
defer sm.Unlock()
for _, service := range sm.serviceStatsMap {
services = append(services, service)
}
sm.hasFreshStats = false
return services
}
if conn == nil || !conn.Connected() {
conn, err = dbus.NewSystemConnectionContext(context.Background())
if err != nil {
return nil
}
defer conn.Close()
}
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, []string{"*.service"})
if err != nil {
slog.Error("Error listing systemd service units", "err", err)
return nil
}
for _, unit := range units {
service, err := sm.updateServiceStats(conn, unit)
if err != nil {
continue
}
services = append(services, service)
}
sm.hasFreshStats = true
return services
}
// updateServiceStats updates the statistics for a single systemd service.
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
sm.Lock()
defer sm.Unlock()
ctx := context.Background()
// if service has never been active (no active since time), skip it
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
return nil, errNoActiveTime
}
} else {
return nil, err
}
service, serviceExists := sm.serviceStatsMap[unit.Name]
if !serviceExists {
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
sm.serviceStatsMap[unit.Name] = service
}
memPeak := service.MemPeak
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
// If memPeak is MaxUint64 the api is saying it's not available
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
memPeak = v
}
}
var memUsage uint64
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
// If memUsage is MaxUint64 the api is saying it's not available
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
memUsage = v
}
}
service.State = systemd.ParseServiceStatus(unit.ActiveState)
service.Sub = systemd.ParseServiceSubState(unit.SubState)
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
if memUsage > memPeak {
memPeak = memUsage
}
var cpuUsage uint64
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
if v, ok := cpuProp.Value.Value().(uint64); ok {
cpuUsage = v
}
}
service.Mem = memUsage
if memPeak > service.MemPeak {
service.MemPeak = memPeak
}
service.UpdateCPUPercent(cpuUsage)
return service, nil
}
// getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
if err != nil {
return nil, err
}
defer conn.Close()
unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
if err != nil {
return nil, err
}
// Start with all unit properties
details := make(systemd.ServiceDetails)
maps.Copy(details, props)
// // Add service-specific properties
servicePropNames := []string{
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
}
for _, propName := range servicePropNames {
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
value := variant.Value.Value()
// Check if the value is MaxUint64, which indicates unlimited/infinite
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
// Set to nil to indicate unlimited - frontend will handle this appropriately
details[propName] = nil
} else {
details[propName] = value
}
}
}
return details, nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") {
return name
}
unescaped, err := strconv.Unquote("\"" + name + "\"")
if err != nil {
return name
}
return unescaped
}

28
agent/systemd_nonlinux.go Normal file
View File

@@ -0,0 +1,28 @@
//go:build !linux
package agent
import (
"errors"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
return &systemdManager{}, nil
}
// getServiceStats returns nil for non-linux systems.
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
return nil
}
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}

View File

@@ -0,0 +1,53 @@
//go:build !linux && testing
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewSystemdManager(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestSystemdManagerGetServiceStats(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// Test with refresh = true
result := manager.getServiceStats(true)
assert.Nil(t, result)
// Test with refresh = false
result = manager.getServiceStats(false)
assert.Nil(t, result)
}
func TestSystemdManagerGetServiceDetails(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
result, err := manager.getServiceDetails("any-service")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
// Test with empty service name
result, err = manager.getServiceDetails("")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
}
func TestSystemdManagerFields(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// The non-linux manager should be a simple struct with no special fields
// We can't test private fields directly, but we can test the methods work
assert.NotNil(t, manager)
}

48
agent/systemd_test.go Normal file
View File

@@ -0,0 +1,48 @@
//go:build linux && testing
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUnescapeServiceName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"nginx.service", "nginx.service"}, // No escaping needed
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
{"no-escape-here", "no-escape-here"}, // No escape sequences
{"", ""}, // Empty string
{"\\x2d\\x2d", "--"}, // Multiple escapes
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := unescapeServiceName(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string
invalidInputs := []string{
"invalid\\x", // Incomplete escape
"invalid\\xZZ", // Invalid hex
"invalid\\x2", // Incomplete hex
"invalid\\xyz", // Not a valid escape
}
for _, input := range invalidInputs {
t.Run(input, func(t *testing.T) {
result := unescapeServiceName(input)
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
})
}
}

View File

@@ -0,0 +1,130 @@
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)
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.15.2"
Version = "0.16.0-beta.1"
// AppName is the name of the application.
AppName = "beszel"
)

8
go.mod
View File

@@ -4,15 +4,16 @@ go 1.25.3
require (
github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.6.0
github.com/distatus/battery v0.11.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.11.0
github.com/nicholas-fedor/shoutrrr v0.11.1
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.31.0
github.com/shirou/gopsutil/v4 v4.25.9
github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
@@ -32,11 +33,12 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ganigeorgiev/fexpr v0.5.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-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect

22
go.sum
View File

@@ -9,6 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -31,8 +33,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
@@ -49,6 +51,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -79,10 +83,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.11.0 h1:hAMv2uM8OfFXkMHVP977elkP3Wgw5/YpVX5GxXQwiWA=
github.com/nicholas-fedor/shoutrrr v0.11.0/go.mod h1:0kRF9ral22xUn/0BlxfhLQUeJDTySCPsuNvaclyagb4=
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
github.com/nicholas-fedor/shoutrrr v0.11.1 h1:DND1gW8UM8GYG8c0bUZ5fPFAnm3id8noPdfaFBUmezk=
github.com/nicholas-fedor/shoutrrr v0.11.1/go.mod h1:RZuSZSEaSimS47zTOLXb6HJDwLjDHiuJ9SrzxsDcWaQ=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -99,8 +103,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
@@ -120,8 +124,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@@ -40,13 +40,18 @@ type UserNotificationSettings struct {
}
type SystemAlertStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
}
type SystemAlertGPUData struct {
Usage float64 `json:"u"`
}
type SystemAlertData struct {

View File

@@ -161,19 +161,14 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// return errs["user"]
// }
// user := alertRecord.ExpandedOne("user")
// if user == nil {
// return nil
// }
// Get system ID for the link
systemID := alertRecord.GetString("system")
return am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
Title: title,
Message: message,
Link: am.hub.MakeLink("system", systemName),
Link: am.hub.MakeLink("system", systemID),
LinkText: "View " + systemName,
})
}

View File

@@ -64,6 +64,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "LoadAvg15":
val = data.Info.LoadAvg[2]
unit = ""
case "GPU":
val = data.Info.GpuPct
}
triggered := alertRecord.GetBool("triggered")
@@ -206,6 +208,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
alert.val += stats.LoadAvg[1]
case "LoadAvg15":
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:
continue
}
@@ -268,9 +281,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
alert.name = after + "m Load"
}
// make title alert name lowercase if not CPU
// make title alert name lowercase if not CPU or GPU
titleAlertName := alert.name
if titleAlertName != "CPU" {
if titleAlertName != "CPU" && titleAlertName != "GPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
@@ -298,7 +311,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
UserID: alert.alertRecord.GetString("user"),
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemName),
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
LinkText: "View " + systemName,
})
}

View File

@@ -3,6 +3,7 @@ package common
import (
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
type WebSocketAction = uint8
@@ -18,6 +19,8 @@ const (
GetContainerInfo
// Request SMART data from agent
GetSmartData
// Request detailed systemd service info from agent
GetSystemdInfo
// Add new actions here...
)
@@ -36,6 +39,7 @@ type AgentResponse struct {
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"`
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
}
@@ -65,3 +69,7 @@ type ContainerLogsRequest struct {
type ContainerInfoRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
type SystemdInfoRequest struct {
ServiceName string `cbor:"0,keyasint"`
}

View File

@@ -2,7 +2,6 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
WORKDIR /app
COPY ../go.mod ../go.sum ./
RUN go mod download
@@ -13,7 +12,24 @@ COPY . ./
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
RUN rm -rf /tmp/*
# --------------------------
# Smartmontools builder stage
# --------------------------
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 AS smartmontools-builder
RUN apt-get update && apt-get install -y \
wget \
build-essential \
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
&& tar zxvf smartmontools-7.5.tar.gz \
&& cd smartmontools-7.5 \
&& ./configure --prefix=/usr --sysconfdir=/etc \
&& make \
&& make install \
&& rm -rf /smartmontools-7.5* \
&& apt-get remove -y wget build-essential \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# --------------------------
# Final image: GPU-enabled agent with nvidia-smi
@@ -21,10 +37,8 @@ RUN rm -rf /tmp/*
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
COPY --from=builder /agent /agent
# this is so we don't need to create the /tmp directory in the scratch container
COPY --from=builder /tmp /tmp
RUN apt-get update && apt-get install -y smartmontools && rm -rf /var/lib/apt/lists/*
# Copy smartmontools binaries and config files
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
# Ensure data persistence across container recreations
VOLUME ["/var/lib/beszel-agent"]

View File

@@ -3,9 +3,11 @@ package system
// TODO: this is confusing, make common package with common/types common/helpers etc
import (
"encoding/json"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/systemd"
)
type Stats struct {
@@ -41,9 +43,28 @@ type Stats struct {
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, 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]
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
}
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
// JSON: encodes as array of numbers (avoids base64 string).
// CBOR: falls back to default handling for []uint8 (byte string), keeping payload small.
type Uint8Slice []uint8
func (s Uint8Slice) MarshalJSON() ([]byte, error) {
if s == nil {
return []byte("null"), nil
}
// Convert to wider ints to force array-of-numbers encoding.
arr := make([]uint16, len(s))
for i, v := range s {
arr[i] = uint16(v)
}
return json.Marshal(arr)
}
type GPUData struct {
@@ -78,6 +99,11 @@ type FsStats struct {
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
}
// ExtraFsInfo contains summary info for extra filesystems in the system info
type ExtraFsInfo struct {
DiskPct float64 `json:"dp" cbor:"0,keyasint"`
}
type NetIoStats struct {
BytesRecv uint64
BytesSent uint64
@@ -125,11 +151,13 @@ type Info struct {
// TODO: remove load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]ExtraFsInfo `json:"efsp,omitempty" cbor:"21,keyasint,omitempty"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
}

View File

@@ -0,0 +1,127 @@
package systemd
import (
"math"
"runtime"
"time"
)
// ServiceState represents the status of a systemd service
type ServiceState uint8
const (
StatusActive ServiceState = iota
StatusInactive
StatusFailed
StatusActivating
StatusDeactivating
StatusReloading
)
// ServiceSubState represents the sub status of a systemd service
type ServiceSubState uint8
const (
SubStateDead ServiceSubState = iota
SubStateRunning
SubStateExited
SubStateFailed
SubStateUnknown
)
// ParseServiceStatus converts a string status to a ServiceStatus enum value
func ParseServiceStatus(status string) ServiceState {
switch status {
case "active":
return StatusActive
case "inactive":
return StatusInactive
case "failed":
return StatusFailed
case "activating":
return StatusActivating
case "deactivating":
return StatusDeactivating
case "reloading":
return StatusReloading
default:
return StatusInactive
}
}
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
func ParseServiceSubState(subState string) ServiceSubState {
switch subState {
case "dead":
return SubStateDead
case "running":
return SubStateRunning
case "exited":
return SubStateExited
case "failed":
return SubStateFailed
default:
return SubStateUnknown
}
}
// Service represents a single systemd service with its stats.
type Service struct {
Name string `json:"n" cbor:"0,keyasint"`
State ServiceState `json:"s" cbor:"1,keyasint"`
Cpu float64 `json:"c" cbor:"2,keyasint"`
Mem uint64 `json:"m" cbor:"3,keyasint"`
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
PrevCpuUsage uint64 `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// UpdateCPUPercent calculates the CPU usage percentage for the service.
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
now := time.Now()
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
s.Cpu = 0
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
duration := now.Sub(s.PrevReadTime).Nanoseconds()
if duration <= 0 {
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
coreCount := int64(runtime.NumCPU())
duration *= coreCount
usageDelta := cpuUsage - s.PrevCpuUsage
cpuPercent := float64(usageDelta) / float64(duration)
s.Cpu = twoDecimals(cpuPercent * 100)
if s.Cpu > s.CpuPeak {
s.CpuPeak = s.Cpu
}
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
// ServiceDependency represents a unit that the service depends on.
type ServiceDependency struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
ActiveState string `json:"activeState,omitempty"`
SubState string `json:"subState,omitempty"`
}
// ServiceDetails contains extended information about a systemd service.
type ServiceDetails map[string]any

View File

@@ -0,0 +1,113 @@
//go:build testing
package systemd_test
import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
func TestParseServiceStatus(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceState
}{
{"active", systemd.StatusActive},
{"inactive", systemd.StatusInactive},
{"failed", systemd.StatusFailed},
{"activating", systemd.StatusActivating},
{"deactivating", systemd.StatusDeactivating},
{"reloading", systemd.StatusReloading},
{"unknown", systemd.StatusInactive}, // default case
{"", systemd.StatusInactive}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceStatus(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestParseServiceSubState(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceSubState
}{
{"dead", systemd.SubStateDead},
{"running", systemd.SubStateRunning},
{"exited", systemd.SubStateExited},
{"failed", systemd.SubStateFailed},
{"unknown", systemd.SubStateUnknown},
{"other", systemd.SubStateUnknown}, // default case
{"", systemd.SubStateUnknown}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceSubState(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestServiceUpdateCPUPercent(t *testing.T) {
t.Run("initial call sets CPU to 0", func(t *testing.T) {
service := &systemd.Service{}
service.UpdateCPUPercent(1000)
assert.Equal(t, 0.0, service.Cpu)
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
assert.False(t, service.PrevReadTime.IsZero())
})
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
// CPU usage should be positive and reasonable
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
})
t.Run("CPU peak updates only when higher", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
initialPeak := service.CpuPeak
// Now try with much lower CPU usage - should not update peak
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(1000000) // Much lower usage
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
})
t.Run("handles zero duration", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
now := time.Now()
service.PrevReadTime = now
// Mock time.Now() to return the same time to ensure zero duration
// Since we can't mock time in Go easily, we'll check the logic manually
// The zero duration case happens when duration <= 0
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
})
t.Run("handles CPU usage wraparound", func(t *testing.T) {
service := &systemd.Service{}
// Simulate wraparound where new usage is less than previous
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(500) // Less than previous, should reset
assert.Equal(t, 0.0, service.Cpu)
})
}

View File

@@ -270,6 +270,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// get SMART data
apiAuth.GET("/smart", h.getSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -342,6 +344,27 @@ func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
}, "info")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// getSmartData handles GET /api/beszel/smart requests
func (h *Hub) getSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math/rand"
"net"
"strings"
@@ -15,6 +16,7 @@ import (
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel"
@@ -171,6 +173,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return err
}
}
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
return err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
@@ -184,11 +194,50 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return systemRecord, err
}
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, service := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = getSystemdServiceId(systemId, service.Name)
params["name"+suffix] = service.Name
params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub
params["cpu"+suffix] = service.Cpu
params["cpuPeak"+suffix] = service.CpuPeak
params["memory"+suffix] = service.Mem
params["memPeak"+suffix] = service.MemPeak
}
queryString := fmt.Sprintf(
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
// getSystemdServiceId generates a deterministic unique id for a systemd service
func getSystemdServiceId(systemId string, serviceName string) string {
hash := fnv.New32a()
hash.Write([]byte(systemId + serviceName))
return fmt.Sprintf("%x", hash.Sum32())
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
@@ -340,6 +389,52 @@ func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, erro
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
}
var result systemd.ServiceDetails
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
return false, err
}
_ = stdin.Close()
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return false, err
}
if resp.ServiceInfo == nil {
if resp.Error != "" {
return false, errors.New(resp.Error)
}
return false, errors.New("no systemd info in response")
}
result = resp.ServiceInfo
return false, nil
})
return result, err
}
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
// fetch via websocket

View File

@@ -0,0 +1,75 @@
//go:build testing
package systems
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSystemdServiceId(t *testing.T) {
t.Run("deterministic output", func(t *testing.T) {
systemId := "sys-123"
serviceName := "nginx.service"
// Call multiple times and ensure same result
id1 := getSystemdServiceId(systemId, serviceName)
id2 := getSystemdServiceId(systemId, serviceName)
id3 := getSystemdServiceId(systemId, serviceName)
assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3)
assert.NotEmpty(t, id1)
})
t.Run("different inputs produce different ids", func(t *testing.T) {
systemId1 := "sys-123"
systemId2 := "sys-456"
serviceName1 := "nginx.service"
serviceName2 := "apache.service"
id1 := getSystemdServiceId(systemId1, serviceName1)
id2 := getSystemdServiceId(systemId2, serviceName1)
id3 := getSystemdServiceId(systemId1, serviceName2)
id4 := getSystemdServiceId(systemId2, serviceName2)
// All IDs should be different
assert.NotEqual(t, id1, id2)
assert.NotEqual(t, id1, id3)
assert.NotEqual(t, id1, id4)
assert.NotEqual(t, id2, id3)
assert.NotEqual(t, id2, id4)
assert.NotEqual(t, id3, id4)
})
t.Run("consistent length", func(t *testing.T) {
testCases := []struct {
systemId string
serviceName string
}{
{"short", "short.service"},
{"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
{"", "empty-system.service"},
{"empty-service", ""},
{"", ""},
}
for _, tc := range testCases {
id := getSystemdServiceId(tc.systemId, tc.serviceName)
// FNV-32 produces 8 hex characters
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
}
})
t.Run("hexadecimal output", func(t *testing.T) {
id := getSystemdServiceId("test-system", "test-service")
assert.NotEmpty(t, id)
// Should only contain hexadecimal characters
for _, char := range id {
assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
"ID should only contain hexadecimal characters, got: %s", id)
}
})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
@@ -115,6 +116,44 @@ func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string)
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
if !ws.IsConnected() {
return nil, gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
if err != nil {
return nil, err
}
var result systemd.ServiceDetails
handler := &systemdInfoHandler{result: &result}
if err := ws.handleAgentRequest(req, handler); err != nil {
return nil, err
}
return result, nil
}
// systemdInfoHandler parses ServiceDetails from AgentResponse
type systemdInfoHandler struct {
BaseHandler
result *systemd.ServiceDetails
}
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.ServiceInfo == nil {
return errors.New("no systemd info in response")
}
*h.result = agentResponse.ServiceInfo
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// RequestSmartData requests SMART data via WebSocket.
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
if !ws.IsConnected() {

View File

@@ -0,0 +1,75 @@
//go:build testing
package ws
import (
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
func TestSystemdInfoHandlerSuccess(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test successful handling with valid ServiceInfo
testDetails := systemd.ServiceDetails{
"Id": "nginx.service",
"ActiveState": "active",
"SubState": "running",
"Description": "A high performance web server",
"ExecMainPID": 1234,
"MemoryCurrent": 1024000,
}
response := common.AgentResponse{
ServiceInfo: testDetails,
}
err := handler.Handle(response)
assert.NoError(t, err)
assert.Equal(t, testDetails, *handler.result)
}
func TestSystemdInfoHandlerError(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test error handling when ServiceInfo is nil
response := common.AgentResponse{
ServiceInfo: nil,
Error: "service not found",
}
err := handler.Handle(response)
assert.Error(t, err)
assert.Equal(t, "no systemd info in response", err.Error())
}
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test with completely empty response
response := common.AgentResponse{}
err := handler.Handle(response)
assert.Error(t, err)
assert.Equal(t, "no systemd info in response", err.Error())
}
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
handler := &systemdInfoHandler{
result: &systemd.ServiceDetails{},
}
// Test that legacy format is not supported
err := handler.HandleLegacy([]byte("some data"))
assert.Error(t, err)
assert.Equal(t, "legacy format not supported", err.Error())
}

View File

@@ -75,6 +75,7 @@ func init() {
"Disk",
"Temperature",
"Bandwidth",
"GPU",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15"
@@ -718,7 +719,9 @@ func init() {
"type": "autodate"
}
],
"indexes": [],
"indexes": [
"CREATE INDEX ` + "`" + `idx_systems_status` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `status` + "`" + `)"
],
"system": false
},
{
@@ -1005,6 +1008,148 @@ func init() {
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 6,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "number2063623452",
"max": null,
"min": null,
"name": "state",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1476559580",
"max": null,
"min": null,
"name": "sub",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3128971310",
"max": null,
"min": null,
"name": "cpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1052053287",
"max": null,
"min": null,
"name": "cpuPeak",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": null,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1828797201",
"max": null,
"min": null,
"name": "memPeak",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3332085495",
"max": null,
"min": null,
"name": "updated",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}
],
"id": "pbc_3494996990",
"indexes": [
"CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "systemd_services",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}
]`

View File

@@ -1,50 +0,0 @@
package migrations
import (
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
// This can be deleted after Nov 2025 or so
func init() {
m.Register(func(app core.App) error {
app.RunInTransaction(func(txApp core.App) error {
var systemIds []string
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
for _, systemId := range systemIds {
var statRecordIds []string
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
for _, statRecordId := range statRecordIds {
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
if err != nil {
return err
}
var systemStats system.Stats
err = statRecord.UnmarshalJSONField("stats", &systemStats)
if err != nil {
return err
}
// if mem buff cache is less than total mem, we don't need to fix it
if systemStats.MemBuffCache < systemStats.Mem {
continue
}
systemStats.MemBuffCache = 0
statRecord.Set("stats", systemStats)
err = txApp.SaveNoValidate(statRecord)
if err != nil {
return err
}
}
}
return nil
})
return nil
}, func(app core.App) error {
return nil
})
}

View File

@@ -177,6 +177,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
stats := &tempStats
// necessary because uint8 is not big enough for the sum
batterySum := 0
// accumulate per-core usage across records
var cpuCoresSums []uint64
// accumulate cpu breakdown [user, system, iowait, steal, idle]
var cpuBreakdownSums []float64
count := float64(len(records))
tempCount := float64(0)
@@ -194,6 +198,15 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
}
sum.Cpu += stats.Cpu
// accumulate cpu time breakdowns if present
if stats.CpuBreakdown != nil {
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
}
for i, v := range stats.CpuBreakdown {
cpuBreakdownSums[i] += v
}
}
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
sum.MemPct += stats.MemPct
@@ -217,6 +230,17 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskIO[1] += stats.DiskIO[1]
batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1]
// accumulate per-core usage if present
if stats.CpuCoresUsage != nil {
if len(cpuCoresSums) < len(stats.CpuCoresUsage) {
// extend slices to accommodate core count
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
}
for i, v := range stats.CpuCoresUsage {
cpuCoresSums[i] += uint64(v)
}
}
// Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
@@ -385,6 +409,25 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.GPUData[id] = gpu
}
}
// Average per-core usage
if len(cpuCoresSums) > 0 {
avg := make(system.Uint8Slice, len(cpuCoresSums))
for i := range cpuCoresSums {
v := math.Round(float64(cpuCoresSums[i]) / count)
avg[i] = uint8(v)
}
sum.CpuCoresUsage = avg
}
// Average CPU breakdown
if len(cpuBreakdownSums) > 0 {
avg := make([]float64, len(cpuBreakdownSums))
for i := range cpuBreakdownSums {
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
}
sum.CpuBreakdown = avg
}
}
return sum
@@ -447,6 +490,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil {
return err
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
@@ -516,6 +563,20 @@ func deleteOldSystemStats(app core.App) error {
return nil
}
// Deletes systemd service records that haven't been updated in the last 20 minutes
func deleteOldSystemdServiceRecords(app core.App) error {
now := time.Now().UTC()
twentyMinutesAgo := now.Add(-20 * time.Minute)
// Delete systemd service records where updated < twentyMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old systemd service records: %v", err)
}
return nil
}
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()

View File

@@ -351,6 +351,83 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
})
}
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user and system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Create old systemd service records that should be deleted (older than 20 minutes)
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0, // Active
"sub": 1, // Running
"cpu": 5.0,
"cpuPeak": 10.0,
"memory": 1024000,
"memPeak": 2048000,
})
require.NoError(t, err)
// Set updated time to 25 minutes ago (should be deleted)
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
err = hub.SaveNoValidate(oldRecord)
require.NoError(t, err)
// Create recent systemd service record that should be kept (within 20 minutes)
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "apache.service",
"state": 1, // Inactive
"sub": 0, // Dead
"cpu": 2.0,
"cpuPeak": 3.0,
"memory": 512000,
"memPeak": 1024000,
})
require.NoError(t, err)
// Set updated time to 10 minutes ago (should be kept)
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
err = hub.SaveNoValidate(recentRecord)
require.NoError(t, err)
// Count records before deletion
countBefore, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
// Run deletion via RecordManager
rm.DeleteOldRecords()
// Count records after deletion
countAfter, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
// Verify the correct record was kept
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
require.NoError(t, err)
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
}
// TestRecordManagerCreation tests RecordManager creation
func TestRecordManagerCreation(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())

View File

@@ -1,12 +1,12 @@
{
"name": "beszel",
"version": "0.15.2",
"version": "0.15.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beszel",
"version": "0.15.2",
"version": "0.15.4",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.15.2",
"version": "0.15.4",
"type": "module",
"scripts": {
"dev": "vite --host",

View File

@@ -17,6 +17,7 @@ export type DataPoint = {
dataKey: (data: SystemStatsRecord) => number | undefined
color: number | string
opacity: number
stackId?: string | number
}
export default function AreaChartDefault({
@@ -29,19 +30,25 @@ export default function AreaChartDefault({
domain,
legend,
itemSorter,
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
}: // logRender = false,
{
chartData: ChartData
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
legend?: boolean
itemSorter?: (a: any, b: any) => number
// logRender?: boolean
}) {
{
chartData: ChartData
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
legend?: boolean
showTotal?: boolean
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
// logRender?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
@@ -56,21 +63,29 @@ export default function AreaChartDefault({
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={chartData.systemStats}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
@@ -81,6 +96,7 @@ export default function AreaChartDefault({
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
/>
}
/>
@@ -99,13 +115,14 @@ export default function AreaChartDefault({
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
stackId={dataPoint.stackId}
/>
)
})}
{legend && <ChartLegend content={<ChartLegendContent />} />}
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
</AreaChart>
</ChartContainer>
</div>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
}

View File

@@ -41,7 +41,7 @@ export default memo(function ContainerChart({
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = toFixedFloat(value, 2) + unit
const val = `${toFixedFloat(value, 2)}%`
return updateYAxisWidth(val)
}
} else {
@@ -78,7 +78,7 @@ export default memo(function ContainerChart({
return `${decimalString(value)} ${unit}`
}
} else {
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
}
// data function
if (isNetChart) {
@@ -139,7 +139,7 @@ export default memo(function ContainerChart({
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filteredKeys.has(key)

View File

@@ -69,7 +69,7 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
const hue = ((i * 360) / count) % 360
chartConfig[containerName] = {
label: containerName,
color: `hsl(${hue}, 60%, 55%)`,
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
}
}

View File

@@ -61,6 +61,7 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
showTotal={true}
/>
}
/>

View File

@@ -35,6 +35,7 @@ import { getPagePath } from "@nanostores/router"
const syntaxTheme = "github-dark-dimmed"
export default function ContainersTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<ContainerRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-c-${systemId ? 1 : 0}`,
@@ -47,56 +48,53 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
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 })
}
function fetchData(systemId?: string) {
pb.collection<ContainerRecord>("containers")
.getList(0, 2000, {
...pbOptions,
filter,
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(({ items }) => setData((curItems) => {
const containerIds = new Set(items.map(item => item.id))
const now = Date.now()
for (const item of curItems) {
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
items.push(item)
.then(({ items }) => items.length && setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set()
const newItems = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
containerIds.add(item.id)
newItems.push(item)
}
}
return items
for (const item of curItems) {
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
newItems.push(item)
}
}
return newItems
}))
}
// initial load
fetchData(70_000)
fetchData(systemId)
// if no systemId, poll every 10 seconds
// if no systemId, pull system containers after every system update
if (!systemId) {
// poll every 10 seconds
const intervalId = setInterval(() => fetchData(10_500), 10_000)
// clear interval on unmount
return () => clearInterval(intervalId)
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) => {
const changeTime = Date.now()
setTimeout(() => fetchData(Date.now() - changeTime + 1000), 100)
fetchData(systemId)
})
}, [])
const table = useReactTable({
data,
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -164,77 +162,78 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
)
}
const AllContainersTable = memo(
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
// 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>({
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">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<ContainerTableRow
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>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
)
const AllContainersTable = memo(function AllContainersTable({
table,
rows,
colLength,
}: {
table: TableType<ContainerRecord>
rows: Row<ContainerRecord>[]
colLength: number
}) {
// 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>({
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">
<ContainersTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <ContainerTableRow 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>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
)
})
async function getLogsHtml(container: ContainerRecord): Promise<string> {
try {
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
system: container.system,
container: container.id,
})])
const [{ highlighter }, logsHtml] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
system: container.system,
container: container.id,
}),
])
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
} catch (error) {
console.error(error)
@@ -244,10 +243,13 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
async function getInfoHtml(container: ContainerRecord): Promise<string> {
try {
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
system: container.system,
container: container.id,
})])
let [{ highlighter }, { info }] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ info: string }>("/api/beszel/containers/info", {
system: container.system,
container: container.id,
}),
])
try {
info = JSON.stringify(JSON.parse(info), null, 2)
} catch (_) { }
@@ -258,7 +260,15 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
}
}
function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject<ContainerRecord | null> }) {
function ContainerSheet({
sheetOpen,
setSheetOpen,
activeContainer,
}: {
sheetOpen: boolean
setSheetOpen: (open: boolean) => void
activeContainer: RefObject<ContainerRecord | null>
}) {
const container = activeContainer.current
if (!container) return null
@@ -297,14 +307,14 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
useEffect(() => {
setLogsDisplay("")
setInfoDisplay("");
setInfoDisplay("")
if (!container) return
(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20)
})()
; (async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20)
})()
}, [container])
return (
@@ -328,7 +338,9 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
<SheetHeader>
<SheetTitle>{container.name}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
{$allSystemsById.get()[container.system]?.name ?? ""}
</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.status}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
@@ -350,19 +362,20 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
disabled={isRefreshingLogs}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
/>
</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" />
</Button>
</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"])}>
<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"]
)}
>
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
<div className="flex items-center w-full">
@@ -376,15 +389,18 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
<MaximizeIcon className="size-4" />
</Button>
</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")}>
<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"
)}
>
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</SheetContent>
</Sheet>
</>
)
}
@@ -406,39 +422,51 @@ function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
)
}
const ContainerTableRow = memo(
function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => 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>
)
}
)
const ContainerTableRow = memo(function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => 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>
)
})
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
function LogsFullscreenDialog({
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)
useEffect(() => {
@@ -471,16 +499,24 @@ function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName,
title={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>
</DialogContent>
</Dialog>
)
}
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
function InfoFullscreenDialog({
open,
onOpenChange,
infoDisplay,
containerName,
}: {
open: boolean
onOpenChange: (open: boolean) => void
infoDisplay: string
containerName: string
}) {
return (
<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">

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time"
@@ -42,7 +42,6 @@ import {
chartTimeData,
cn,
compareSemVer,
debounce,
decimalString,
formatBytes,
secondsToString,
@@ -73,10 +72,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart"
type ChartTimeData = {
time: number
data: {
@@ -97,8 +95,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
}
}
const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now() + buffer)
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
@@ -585,7 +583,12 @@ export default memo(function SystemDetail({ id }: { id: string }) {
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
>
<AreaChartDefault
chartData={chartData}
@@ -693,6 +696,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
@@ -746,6 +750,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
@@ -1001,7 +1006,11 @@ export default memo(function SystemDetail({ id }: { id: string }) {
)}
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={id} />
<LazyContainersTable systemId={system.id} />
)}
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
<LazySystemdTable systemId={system.id} />
)}
</div>
@@ -1034,32 +1043,51 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
}
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const containerFilter = useStore(store)
const storeValue = useStore(store)
const [inputValue, setInputValue] = useState(storeValue)
const { t } = useLingui()
const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store])
useEffect(() => {
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(
(e: React.ChangeEvent<HTMLInputElement>) => debouncedStoreSet(e.target.value),
[debouncedStoreSet]
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
},
[]
)
const handleClear = useCallback(() => {
setInputValue("")
store.set("")
}, [store])
return (
<>
<Input
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={containerFilter}
value={inputValue}
/>
{containerFilter && (
{inputValue && (
<Button
type="button"
variant="ghost"
size="icon"
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"
onClick={() => store.set("")}
onClick={handleClear}
>
<XIcon className="h-4 w-4" />
</Button>
@@ -1119,7 +1147,7 @@ export function ChartCard({
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
</CardHeader>
<div className={cn("ps-0 w-[calc(100%-1.5em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
<div className={cn("ps-0 w-[calc(100%-1.3em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
{
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}
@@ -1153,4 +1181,15 @@ function LazySmartTable({ systemId }: { systemId: string }) {
{isIntersecting && <SmartTable systemId={systemId} />}
</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>
)
}

View File

@@ -0,0 +1,195 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { DialogTitle } from "@/components/ui/dialog"
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../system"
const minAgentVersion = parseSemVer("0.15.3")
export default memo(function CpuCoresSheet({
chartData,
dataEmpty,
grid,
maxValues,
}: {
chartData: ChartData
dataEmpty: boolean
grid: boolean
maxValues: boolean
}) {
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
const hasOpened = useRef(false)
const supportsBreakdown = compareSemVer(chartData.agentVersion, minAgentVersion) >= 0
if (!supportsBreakdown) {
return null
}
if (cpuCoresOpen && !hasOpened.current) {
hasOpened.current = true
}
// Latest stats snapshot
const latest = chartData.systemStats.at(-1)?.stats
const cpus = latest?.cpus ?? []
const numCores = cpus.length
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
const breakdownDataPoints = [
{
label: "System",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
color: 3,
opacity: 0.35,
stackId: "a"
},
{
label: "User",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
color: 1,
opacity: 0.35,
stackId: "a"
},
{
label: "IOWait",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
color: 4,
opacity: 0.35,
stackId: "a"
},
{
label: "Steal",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
color: 5,
opacity: 0.35,
stackId: "a"
},
{
label: "Idle",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
color: 2,
opacity: 0.35,
stackId: "a"
},
{
label: t`Other`,
dataKey: ({ stats }: SystemStatsRecord) => {
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
return total > 0 ? 100 - total : null
},
color: `hsl(80, 65%, 52%)`,
opacity: 0.35,
stackId: "a"
},
] as DataPoint[]
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
{hasBreakdown && (
<ChartCard
key="cpu-breakdown"
empty={dataEmpty}
grid={grid}
title={t`CPU Time Breakdown`}
description={t`Percentage of time spent in each state`}
legend={true}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={true}
dataPoints={breakdownDataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
reverseStackOrder={true}
itemSorter={() => 1}
domain={[0, 100]}
/>
</ChartCard>
)}
{numCores > 0 && (
<ChartCard
key="cpu-cores-all"
empty={dataEmpty}
grid={grid}
title={t`CPU Cores`}
legend={numCores < 10}
description={t`Per-core average utilization`}
className="min-h-auto"
>
<AreaChartDefault
hideYAxis={true}
chartData={chartData}
maxToggled={maxValues}
legend={numCores < 10}
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
label: `CPU ${i}`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
opacity: 0.35,
stackId: "a"
}))}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
reverseStackOrder={true}
itemSorter={() => 1}
/>
</ChartCard>
)}
{Array.from({ length: numCores }).map((_, i) => (
<ChartCard
key={`cpu-core-${i}`}
empty={dataEmpty}
grid={grid}
title={`CPU ${i}`}
description={t`Per-core average utilization`}
legend={false}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={false}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
))}
</SheetContent>
)}
</Sheet>
)
})

View File

@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
<ChartCard
empty={dataEmpty}
grid={grid}

View File

@@ -0,0 +1,200 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { SystemdRecord } from "@/types"
import { ServiceStatus, ServiceStatusLabels, ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
import {
ActivityIcon,
ArrowUpDownIcon,
ClockIcon,
CpuIcon,
MemoryStickIcon,
TerminalSquareIcon,
} from "lucide-react"
import { Badge } from "../ui/badge"
import { t } from "@lingui/core/macro"
// import { $allSystemsById } from "@/lib/stores"
// import { useStore } from "@nanostores/react"
function getSubStateColor(subState: ServiceSubState) {
switch (subState) {
case ServiceSubState.Running:
return "bg-green-500"
case ServiceSubState.Failed:
return "bg-red-500"
case ServiceSubState.Dead:
return "bg-yellow-500"
default:
return "bg-zinc-500"
}
}
export const systemdTableCols: ColumnDef<SystemdRecord>[] = [
{
id: "name",
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
accessorFn: (record) => record.name,
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TerminalSquareIcon} />,
cell: ({ getValue }) => {
return <span className="ms-1.5 xl:w-50 block truncate">{getValue() as string}</span>
},
},
// {
// id: "system",
// accessorFn: (record) => record.system,
// sortingFn: (a, b) => {
// const allSystems = $allSystemsById.get()
// const systemNameA = allSystems[a.original.system]?.name ?? ""
// const systemNameB = allSystems[b.original.system]?.name ?? ""
// return systemNameA.localeCompare(systemNameB)
// },
// header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
// cell: ({ getValue }) => {
// const allSystems = useStore($allSystemsById)
// return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
// },
// },
{
id: "state",
accessorFn: (record) => record.state,
header: ({ column }) => <HeaderButton column={column} name={t`State`} Icon={ActivityIcon} />,
cell: ({ getValue }) => {
const statusValue = getValue() as ServiceStatus
const statusLabel = ServiceStatusLabels[statusValue] || "Unknown"
return (
<Badge variant="outline" className="dark:border-white/12">
<span className={cn("size-2 me-1.5 rounded-full", getStatusColor(statusValue))} />
{statusLabel}
</Badge>
)
},
},
{
id: "sub",
accessorFn: (record) => record.sub,
header: ({ column }) => <HeaderButton column={column} name={t`Sub State`} Icon={ActivityIcon} />,
cell: ({ getValue }) => {
const subState = getValue() as ServiceSubState
const subStateLabel = ServiceSubStateLabels[subState] || "Unknown"
return (
<Badge variant="outline" className="dark:border-white/12 text-xs capitalize">
<span className={cn("size-2 me-1.5 rounded-full", getSubStateColor(subState))} />
{subStateLabel}
</Badge>
)
},
},
{
id: "cpu",
accessorFn: (record) => {
if (record.sub !== ServiceSubState.Running) {
return -1
}
return record.cpu
},
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={`${t`CPU`} (10m)`} Icon={CpuIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
if (val < 0) {
return <span className="ms-1.5 text-muted-foreground">N/A</span>
}
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "cpuPeak",
accessorFn: (record) => {
if (record.sub !== ServiceSubState.Running) {
return -1
}
return record.cpuPeak ?? 0
},
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`CPU Peak`} Icon={CpuIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
if (val < 0) {
return <span className="ms-1.5 text-muted-foreground">N/A</span>
}
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
},
},
{
id: "memory",
accessorFn: (record) => record.memory,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
if (!val) {
return <span className="ms-1.5 text-muted-foreground">N/A</span>
}
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "memPeak",
accessorFn: (record) => record.memPeak,
invertSorting: true,
header: ({ column }) => <HeaderButton column={column} name={t`Memory Peak`} Icon={MemoryStickIcon} />,
cell: ({ getValue }) => {
const val = getValue() as number
if (!val) {
return <span className="ms-1.5 text-muted-foreground">N/A</span>
}
const formatted = formatBytes(val, false, undefined, false)
return (
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
)
},
},
{
id: "updated",
invertSorting: true,
accessorFn: (record) => record.updated,
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
cell: ({ getValue }) => {
const timestamp = getValue() as number
return (
<span className="ms-1.5 tabular-nums">
{hourWithSeconds(new Date(timestamp).toISOString())}
</span>
)
},
},
]
function HeaderButton({ column, name, Icon }: { column: Column<SystemdRecord>; name: string; Icon: React.ElementType }) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{Icon && <Icon className="size-4" />}
{name}
<ArrowUpDownIcon className="size-4" />
</Button>
)
}
export function getStatusColor(status: ServiceStatus) {
switch (status) {
case ServiceStatus.Active:
return "bg-green-500"
case ServiceStatus.Failed:
return "bg-red-500"
case ServiceStatus.Reloading:
case ServiceStatus.Activating:
case ServiceStatus.Deactivating:
return "bg-yellow-500"
default:
return "bg-zinc-500"
}
}

View File

@@ -0,0 +1,665 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { LoaderCircleIcon } from "lucide-react"
import { listenKeys } from "nanostores"
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
import { $allSystemsById } from "@/lib/stores"
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
import { Separator } from "../ui/separator"
export default function SystemdTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<SystemdRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-sd-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
sessionStorage
)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [globalFilter, setGlobalFilter] = useState("")
// clear old data when systemId changes
useEffect(() => {
return setData([])
}, [systemId])
useEffect(() => {
const lastUpdated = data[0]?.updated ?? 0
function fetchData(systemId?: string) {
pb.collection<SystemdRecord>("systemd_services")
.getList(0, 2000, {
fields: "name,state,sub,cpu,cpuPeak,memory,memPeak,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
})
.then(
({ items }) =>
items.length &&
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const systemdNames = new Set()
const newItems: SystemdRecord[] = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
systemdNames.add(item.name)
newItems.push(item)
}
}
for (const item of curItems) {
if (!systemdNames.has(item.name) && lastUpdated - item.updated < 70_000) {
newItems.push(item)
}
}
return newItems
})
)
}
// initial load
fetchData(systemId)
// if no systemId, pull system containers after every system update
if (!systemId) {
return $allSystemsById.listen((_value, _oldValue, systemId) => {
// exclude initial load of systems
if (Date.now() - loadTime > 500) {
fetchData(systemId)
}
})
}
// if systemId, fetch containers after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
// don't fetch data if the last update is less than 9.5 minutes
if (lastUpdated > Date.now() - 9.5 * 60 * 1000) {
return
}
fetchData(systemId)
})
}, [systemId])
const table = useReactTable({
data,
// columns: systemdTableCols.filter((col) => (systemId ? col.id !== "system" : true)),
columns: systemdTableCols,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
defaultColumn: {
sortUndefined: "last",
size: 100,
minSize: 0,
},
state: {
sorting,
columnFilters,
columnVisibility,
globalFilter,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
const service = row.original
const systemName = $allSystemsById.get()[service.system]?.name ?? ""
const name = service.name ?? ""
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
const subState = service.sub ?? ""
const searchString = `${systemName} ${name} ${statusLabel} ${subState}`.toLowerCase()
return (filterValue as string)
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
const rows = table.getRowModel().rows
const visibleColumns = table.getVisibleLeafColumns()
const statusTotals = useMemo(() => {
const totals = [0, 0, 0, 0, 0, 0]
for (const service of data) {
totals[service.state]++
}
return totals
}, [data])
if (!data.length && !globalFilter) {
return null
}
return (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Systemd Services</Trans>
</CardTitle>
<CardDescription className="flex items-center">
<Trans>Total: {data.length}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Updated every 10 minutes.</Trans>
</CardDescription>
</div>
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
/>
</div>
</CardHeader>
<div className="rounded-md">
<AllSystemdTable table={table} rows={rows} colLength={visibleColumns.length} systemId={systemId} />
</div>
</Card>
)
}
const AllSystemdTable = memo(function AllSystemdTable({
table,
rows,
colLength,
systemId,
}: {
table: TableType<SystemdRecord>
rows: Row<SystemdRecord>[]
colLength: number
systemId?: string
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const activeService = useRef<SystemdRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (service: SystemdRecord) => {
activeService.current = service
setSheetOpen(true)
}
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
return (
<div
className={cn(
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
(!rows.length || rows.length > 2) && "min-h-50"
)}
ref={scrollRef}
>
{/* add header height to table size */}
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
<table className="text-sm w-full h-full text-nowrap">
<SystemdTableHead table={table} />
<TableBody>
{rows.length ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
return <SystemdTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
<Trans>No results.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<SystemdSheet
sheetOpen={sheetOpen}
setSheetOpen={setSheetOpen}
activeService={activeService}
systemId={systemId}
/>
</div>
)
})
function SystemdSheet({
sheetOpen,
setSheetOpen,
activeService,
systemId,
}: {
sheetOpen: boolean
setSheetOpen: (open: boolean) => void
activeService: React.RefObject<SystemdRecord | null>
systemId?: string
}) {
const service = activeService.current
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!sheetOpen || !service) {
return
}
setError(null)
let cancelled = false
setDetails(null)
setIsLoading(true)
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
query: {
system: systemId,
service: service.name,
},
})
.then(({ details }) => {
if (cancelled) return
if (details) {
setDetails(details)
} else {
setDetails(null)
setError(t`No systemd details returned`)
}
})
.catch((err) => {
if (cancelled) return
setError(err?.message ?? "Failed to load service details")
setDetails(null)
})
.finally(() => {
if (!cancelled) {
setIsLoading(false)
}
})
return () => {
cancelled = true
}
}, [sheetOpen, service, systemId])
if (!service) return null
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
const subStateLabel = ServiceSubStateLabels[service.sub as ServiceSubState] ?? ""
const notAvailable = <span className="text-muted-foreground">N/A</span>
const formatMemory = (value?: number | null) => {
if (value === undefined || value === null) {
return value === null ? t`Unlimited` : undefined
}
const { value: convertedValue, unit } = formatBytes(value, false, undefined, false)
const digits = convertedValue >= 10 ? 1 : 2
return `${decimalString(convertedValue, digits)} ${unit}`
}
const formatCpuTime = (ns?: number) => {
if (!ns) return undefined
const seconds = ns / 1_000_000_000
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = Math.floor(seconds % 60)
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null]
.filter(Boolean)
.join(" ")
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${minutes}m ${secs}s`
}
if (seconds >= 1) {
return `${decimalString(seconds, 2)}s`
}
return `${decimalString(seconds * 1000, 2)}ms`
}
const formatTasks = (current?: number, max?: number) => {
const hasCurrent = typeof current === "number" && current >= 0
const hasMax = typeof max === "number" && max > 0 && max !== null
if (!hasCurrent && !hasMax) {
return undefined
}
return (
<>
{hasCurrent ? current : notAvailable}
{hasMax && (
<span className="text-muted-foreground ms-1.5">
{t`(limit: ${max})`}
</span>
)}
{max === null && (
<span className="text-muted-foreground ms-1.5">
{t`(limit: unlimited)`}
</span>
)}
</>
)
}
const formatTimestamp = (timestamp?: number) => {
if (!timestamp) return undefined
// systemd timestamps are in microseconds, convert to milliseconds for JavaScript Date
const date = new Date(timestamp / 1000)
if (Number.isNaN(date.getTime())) return undefined
return date.toLocaleString()
}
const activeStateValue = (() => {
const stateText = details?.ActiveState
? details.SubState
? `${details.ActiveState} (${details.SubState})`
: details.ActiveState
: subStateLabel
? `${statusLabel} (${subStateLabel})`
: statusLabel
for (const [index, status] of ServiceStatusLabels.entries()) {
if (details?.ActiveState?.toLowerCase() === status.toLowerCase()) {
service.state = index as ServiceStatus
break
}
}
return (
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor(service.state))} />
{stateText}
</div>
)
})()
const statusTextValue = details?.Result
const cpuTime = formatCpuTime(details?.CPUUsageNSec)
const tasks = formatTasks(details?.TasksCurrent, details?.TasksMax)
const memoryCurrent = formatMemory(details?.MemoryCurrent)
const memoryPeak = formatMemory(details?.MemoryPeak)
const memoryLimit = formatMemory(details?.MemoryLimit)
const restartsValue = typeof details?.NRestarts === "number" ? details.NRestarts : undefined
const mainPidValue = typeof details?.MainPID === "number" && details.MainPID > 0 ? details.MainPID : undefined
const execMainPidValue =
typeof details?.ExecMainPID === "number" && details.ExecMainPID > 0 && details.ExecMainPID !== details?.MainPID
? details.ExecMainPID
: undefined
const activeEnterTimestamp = formatTimestamp(details?.ActiveEnterTimestamp)
const activeExitTimestamp = formatTimestamp(details?.ActiveExitTimestamp)
const inactiveEnterTimestamp = formatTimestamp(details?.InactiveEnterTimestamp)
const execMainStartTimestamp = undefined // Property not available in current systemd interface
const renderRow = (key: string, label: ReactNode, value?: ReactNode, alwaysShow = false) => {
if (!alwaysShow && (value === undefined || value === null || value === "")) {
return null
}
return (
<tr key={key} className="border-b last:border-b-0">
<td className="px-3 py-2 font-medium bg-muted dark:bg-muted/40 align-top w-35">{label}</td>
<td className="px-3 py-2">{value ?? notAvailable}</td>
</tr>
)
}
return (
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
<SheetHeader className="p-0">
<SheetTitle>
<Trans>Service Details</Trans>
</SheetTitle>
</SheetHeader>
<div className="grid gap-6">
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
<Trans>Loading...</Trans>
</div>
)}
{error && (
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
<AlertTitle>
<Trans>Error</Trans>
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div>
<div className="border rounded-md">
<table className="w-full text-sm">
<tbody>
{renderRow("name", t`Name`, service.name, true)}
{renderRow("description", t`Description`, details?.Description, true)}
{renderRow("loadState", t`Load State`, details?.LoadState, true)}
{renderRow(
"bootState",
t`Boot State`,
<div className="flex items-center">
{details?.UnitFileState}
{details?.UnitFilePreset && (
<span className="text-muted-foreground ms-1.5">(preset: {details?.UnitFilePreset})</span>
)}
</div>,
true
)}
{renderRow("unitFile", t`Unit File`, details?.FragmentPath, true)}
{renderRow("active", t`Active State`, activeStateValue, true)}
{renderRow("status", t`Status`, statusTextValue, true)}
{renderRow(
"documentation",
t`Documentation`,
Array.isArray(details?.Documentation) && details.Documentation.length > 0
? details.Documentation.join(", ")
: undefined
)}
</tbody>
</table>
</div>
</div>
<div>
<h3 className="text-sm font-medium mb-3">
<Trans>Runtime Metrics</Trans>
</h3>
<div className="border rounded-md">
<table className="w-full text-sm">
<tbody>
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
{renderRow("execMainPid", t`Exec Main PID`, execMainPidValue)}
{renderRow("tasks", t`Tasks`, tasks, true)}
{renderRow("cpuTime", t`CPU Time`, cpuTime)}
{renderRow("memory", t`Memory`, memoryCurrent, true)}
{renderRow("memoryPeak", t`Memory Peak`, memoryPeak)}
{renderRow("memoryLimit", t`Memory Limit`, memoryLimit)}
{renderRow("restarts", t`Restarts`, restartsValue, true)}
</tbody>
</table>
</div>
</div>
<div className="hidden has-[tr]:block">
<h3 className="text-sm font-medium mb-3">
<Trans>Relationships</Trans>
</h3>
<div className="border rounded-md">
<table className="w-full text-sm">
<tbody>
{renderRow(
"wants",
t`Wants`,
Array.isArray(details?.Wants) && details.Wants.length > 0 ? details.Wants.join(", ") : undefined
)}
{renderRow(
"requires",
t`Requires`,
Array.isArray(details?.Requires) && details.Requires.length > 0
? details.Requires.join(", ")
: undefined
)}
{renderRow(
"requiredBy",
t`Required By`,
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
? details.RequiredBy.join(", ")
: undefined
)}
{renderRow(
"conflicts",
t`Conflicts`,
Array.isArray(details?.Conflicts) && details.Conflicts.length > 0
? details.Conflicts.join(", ")
: undefined
)}
{renderRow(
"before",
t`Before`,
Array.isArray(details?.Before) && details.Before.length > 0 ? details.Before.join(", ") : undefined
)}
{renderRow(
"after",
t`After`,
Array.isArray(details?.After) && details.After.length > 0 ? details.After.join(", ") : undefined
)}
{renderRow(
"triggers",
t`Triggers`,
Array.isArray(details?.Triggers) && details.Triggers.length > 0
? details.Triggers.join(", ")
: undefined
)}
{renderRow(
"triggeredBy",
t`Triggered By`,
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
? details.TriggeredBy.join(", ")
: undefined
)}
</tbody>
</table>
</div>
</div>
<div className="hidden has-[tr]:block">
<h3 className="text-sm font-medium mb-3">
<Trans>Lifecycle</Trans>
</h3>
<div className="border rounded-md">
<table className="w-full text-sm">
<tbody>
{renderRow("activeSince", t`Became Active`, activeEnterTimestamp)}
{service.state !== ServiceStatus.Active &&
renderRow("lastActive", t`Exited Active`, activeExitTimestamp)}
{renderRow("inactiveSince", t`Became Inactive`, inactiveEnterTimestamp)}
{renderRow("execMainStart", t`Process Started`, execMainStartTimestamp)}
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
</tbody>
</table>
</div>
</div>
<div className="hidden has-[tr]:block">
<h3 className="text-sm font-medium mb-3">
<Trans>Capabilities</Trans>
</h3>
<div className="border rounded-md">
<table className="w-full text-sm">
<tbody>
{renderRow("canStart", t`Can Start`, details?.CanStart ? t`Yes` : t`No`)}
{renderRow("canStop", t`Can Stop`, details?.CanStop ? t`Yes` : t`No`)}
{renderRow("canReload", t`Can Reload`, details?.CanReload ? t`Yes` : t`No`)}
{/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? t`Yes` : t`No`)}
{renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */}
</tbody>
</table>
</div>
</div>
</div>
</SheetContent>
</Sheet>
)
}
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</tr>
))}
</TableHeader>
)
}
const SystemdTableRow = memo(function SystemdTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<SystemdRecord>
virtualRow: VirtualItem
openSheet: (service: SystemdRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})

View File

@@ -20,6 +20,7 @@ import {
WifiIcon,
} from "lucide-react"
import { memo, useMemo, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
import { isReadOnlyUser, pb } from "@/lib/api"
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
@@ -153,7 +154,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
accessorFn: ({ info }) => info.dp,
id: "disk",
name: () => t`Disk`,
cell: TableCellWithMeter,
cell: DiskCellWithMultiple,
Icon: HardDriveIcon,
header: sortableHeader,
},
@@ -354,6 +355,92 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
)
}
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
const { info: sysInfo, status } = info.row.original
const rootDiskPct = sysInfo.dp
const extraFsData = sysInfo.efsp
const extraFsCount = extraFsData ? Object.keys(extraFsData).length : 0
const threshold = getMeterState(rootDiskPct)
const meterClass = cn(
"h-full",
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down
)
// No extra disks - show simple meter
if (extraFsCount === 0) {
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
<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">
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
</span>
</div>
)
}
// Has extra disks - show with tooltip
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col gap-0.5 w-full cursor-help 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">
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
</span>
</div>
<div className="text-[0.7rem] text-muted-foreground ps-0.5">+{extraFsCount} more</div>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<div className="flex flex-col gap-2 py-1">
<div className="flex items-center gap-2 text-xs font-medium">
<HardDriveIcon className="size-3" />
<span>{t`All Disks`}</span>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider">{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/50 h-1.5 rounded-sm overflow-hidden">
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
</span>
</div>
</div>
{extraFsData && Object.entries(extraFsData).map(([name, fs]) => {
const pct = fs.dp
const fsThreshold = getMeterState(pct)
const fsMeterClass = cn(
"h-full",
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
(fsThreshold === MeterState.Good && STATUS_COLORS.up) ||
(fsThreshold === MeterState.Warn && STATUS_COLORS.pending) ||
STATUS_COLORS.down
)
return (
<div key={name} className="flex flex-col gap-0.5">
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider 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/50 h-1.5 rounded-sm overflow-hidden">
<span className={fsMeterClass} style={{ width: `${pct}%` }}></span>
</span>
</div>
</div>
)
})}
</div>
</div>
</TooltipContent>
</Tooltip>
)
}
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
return (

View File

@@ -1,8 +1,10 @@
import type { JSX } from "react"
import { useLingui } from "@lingui/react/macro"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { chartTimeData, cn } from "@/lib/utils"
import type { ChartData } from "@/types"
import { Separator } from "./separator"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
@@ -100,6 +102,8 @@ const ChartTooltipContent = React.forwardRef<
filter?: string
contentFormatter?: (item: any, key: string) => React.ReactNode | string
truncate?: boolean
showTotal?: boolean
totalLabel?: React.ReactNode
}
>(
(
@@ -121,11 +125,16 @@ const ChartTooltipContent = React.forwardRef<
itemSorter,
contentFormatter: content = undefined,
truncate = false,
showTotal = false,
totalLabel,
},
ref
) => {
// const { config } = useChart()
const config = {}
const { t } = useLingui()
const totalLabelNode = totalLabel ?? t`Total`
const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total`
React.useMemo(() => {
if (filter) {
@@ -141,6 +150,76 @@ const ChartTooltipContent = React.forwardRef<
}
}, [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(() => {
if (hideLabel || !payload?.length) {
return null
@@ -242,6 +321,15 @@ const ChartTooltipContent = React.forwardRef<
</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>
)
@@ -257,14 +345,17 @@ const ChartLegendContent = React.forwardRef<
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
reverse?: boolean
}
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
// const { config } = useChart()
if (!payload?.length) {
return null
}
const reversedPayload = reverse ? [...payload].reverse() : payload
return (
<div
ref={ref}
@@ -274,7 +365,7 @@ const ChartLegendContent = React.forwardRef<
className
)}
>
{payload.map((item) => {
{reversedPayload.map((item) => {
// const key = `${nameKey || item.dataKey || 'value'}`
// const itemConfig = getPayloadConfigFromPayload(config, item, key)

View File

@@ -32,6 +32,8 @@
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--table-header: hsl(225, 6%, 97%);
--chart-saturation: 65%;
--chart-lightness: 50%;
}
.dark {
@@ -51,11 +53,13 @@
--accent: hsl(220 5% 15.5%);
--accent-foreground: hsl(220 2% 98%);
--destructive: hsl(0 62% 46%);
--border: hsl(220 3% 16%);
--border: hsl(220 3% 17%);
--input: hsl(220 4% 22%);
--ring: hsl(220 4% 80%);
--table-header: hsl(220, 6%, 13%);
--radius: 0.8rem;
--chart-saturation: 60%;
--chart-lightness: 55%;
}
@theme inline {

View File

@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro"
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
import type { RecordSubscription } from "pocketbase"
import { EthernetIcon } from "@/components/ui/icons"
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
import { $alerts } from "@/lib/stores"
import type { AlertInfo, AlertRecord } from "@/types"
import { pb } from "./api"
@@ -41,6 +41,12 @@ export const alertInfo: Record<string, AlertInfo> = {
desc: () => t`Triggers when combined up/down exceeds a threshold`,
max: 125,
},
GPU: {
name: () => t`GPU Usage`,
unit: "%",
icon: GpuIcon,
desc: () => t`Triggers when GPU usage exceeds a threshold`,
},
Temperature: {
name: () => t`Temperature`,
unit: "°C",

View File

@@ -71,3 +71,26 @@ export enum ConnectionType {
}
export const connectionTypeLabels = ["", "SSH", "WebSocket"] as const
/** Systemd service state */
export enum ServiceStatus {
Active,
Inactive,
Failed,
Activating,
Deactivating,
Reloading,
}
export const ServiceStatusLabels = ["Active", "Inactive", "Failed", "Activating", "Deactivating", "Reloading"] as const
/** Systemd service sub state */
export enum ServiceSubState {
Dead,
Running,
Exited,
Failed,
Unknown,
}
export const ServiceSubStateLabels = ["Dead", "Running", "Exited", "Failed", "Unknown"] as const

View File

@@ -7,13 +7,15 @@ import { messages as enMessages } from "@/locales/en/en"
import { BatteryState } from "./enums"
import { $direction } from "./stores"
const rtlLanguages = new Set(["ar", "fa", "he"])
// activates locale
function activateLocale(locale: string, messages: Messages = enMessages) {
i18n.load(locale, messages)
i18n.activate(locale)
document.documentElement.lang = locale
localStorage.setItem("lang", locale)
$direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
$direction.set(rtlLanguages.has(locale) ? "rtl" : "ltr")
}
// dynamically loads translations for the given locale

View File

@@ -44,6 +44,11 @@ export default [
label: "Français",
e: "🇫🇷",
},
{
lang: "he",
label: "עברית",
e: "🕎",
},
{
lang: "hr",
label: "Hrvatski",

View File

@@ -287,7 +287,7 @@ export function formatBytes(
}
}
export const chartMargin = { top: 12 }
export const chartMargin = { top: 12, right: 5 }
/**
* Retuns value of system host, truncating full path if socket.

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-30 21:52\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
@@ -370,8 +370,17 @@ msgstr "نسخ YAML"
msgid "CPU"
msgstr "المعالج"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "نوى المعالج"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "تفصيل وقت المعالج"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "استخدام وحدة المعالجة المركزية"
@@ -833,6 +842,10 @@ msgstr "فتح القائمة"
msgid "Or continue with"
msgstr "أو المتابعة باستخدام"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "أخرى"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "الكتابة فوق التنبيهات الحالية"
@@ -881,6 +894,15 @@ msgstr "متوقف مؤقتا"
msgid "Paused ({pausedSystemsLength})"
msgstr "متوقف مؤقتا ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "متوسط الاستخدام لكل نواة"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
@@ -1177,6 +1199,11 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "الإجمالي"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "إجمالي البيانات المستلمة لكل واجهة"
@@ -1266,6 +1293,7 @@ msgstr "مدة التشغيل"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "الاستخدام"
@@ -1291,6 +1319,7 @@ msgstr "القيمة"
msgid "View"
msgstr "عرض"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "عرض المزيد"

View File

@@ -370,8 +370,17 @@ msgstr "Копирай YAML"
msgid "CPU"
msgstr "Процесор"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU ядра"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Разбивка на времето на CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Употреба на процесор"
@@ -833,6 +842,10 @@ msgstr "Отвори менюто"
msgid "Or continue with"
msgstr "Или продължи с"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Други"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Презапиши съществуващи тревоги"
@@ -881,6 +894,15 @@ msgstr "На пауза"
msgid "Paused ({pausedSystemsLength})"
msgstr "На пауза ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Средно използване на ядро"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Процент време, прекарано във всяко състояние"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Моля <0>конфигурурай SMTP сървър</0> за да се подсигуриш, че тревогите са доставени."
@@ -1177,6 +1199,11 @@ msgstr "Токените позволяват на агентите да се с
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Токените и пръстовите отпечатъци се използват за удостоверяване на WebSocket връзките към концентратора."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Общо"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Общо получени данни за всеки интерфейс"
@@ -1266,6 +1293,7 @@ msgstr "Време на работа"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Употреба"
@@ -1291,6 +1319,7 @@ msgstr "Стойност"
msgid "View"
msgstr "Изглед"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Виж повече"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -43,7 +43,7 @@ msgstr "1 hodina"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
@@ -60,7 +60,7 @@ msgstr "12 hodin"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
msgstr ""
#: src/lib/utils.ts
msgid "24 hours"
@@ -73,7 +73,7 @@ msgstr "30 dní"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
msgstr ""
#. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -117,7 +117,7 @@ msgstr "Administrátor"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -370,8 +370,17 @@ msgstr "Kopírovat YAML"
msgid "CPU"
msgstr "Procesor"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU jádra"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Rozdělení času CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Využití procesoru"
@@ -426,7 +435,7 @@ msgstr "Smazat identifikátor"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Device"
@@ -439,11 +448,11 @@ msgstr "Vybíjení"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr ""
#: src/components/routes/system.tsx
msgid "Disk I/O"
msgstr "Disk I/O"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
@@ -504,7 +513,7 @@ msgstr "Upravit"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr "Email"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -597,7 +606,7 @@ msgstr "Otisk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -755,7 +764,7 @@ msgstr "Využití paměti docker kontejnerů"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Otevřít menu"
msgid "Or continue with"
msgstr "Nebo pokračujte s"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Jiné"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Přepsat existující upozornění"
@@ -881,6 +894,15 @@ msgstr "Pozastaveno"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pozastaveno ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Průměrné využití na jádro"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Procento času strávěného v každém stavu"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena."
@@ -916,7 +938,7 @@ msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1161,7 +1183,7 @@ msgstr "Přepnout motiv"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Celkový přijatý objem dat pro každé rozhraní"
@@ -1266,6 +1293,7 @@ msgstr "Doba provozu"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Využití"
@@ -1291,6 +1319,7 @@ msgstr "Hodnota"
msgid "View"
msgstr "Zobrazení"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Zobrazit více"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-25 10:58\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "Kopier YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU-kerner"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU-tidsfordeling"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU forbrug"
@@ -597,7 +606,7 @@ msgstr "Fingeraftryk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -766,7 +775,7 @@ msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr "Net"
msgstr ""
#: src/components/routes/system.tsx
msgid "Network traffic of docker containers"
@@ -833,6 +842,10 @@ msgstr "Åbn menu"
msgid "Or continue with"
msgstr "Eller fortsæt med"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Andre"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Overskriv eksisterende alarmer"
@@ -881,6 +894,15 @@ msgstr "Sat på pause"
msgid "Paused ({pausedSystemsLength})"
msgstr "Sat på pause ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Gennemsnitlig udnyttelse pr. kerne"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Procentdel af tid brugt i hver tilstand"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Konfigurer <0>en SMTP server</0> for at sikre at alarmer bliver leveret."
@@ -1177,6 +1199,11 @@ msgstr "Nøgler tillader agenter at oprette forbindelse og registrere. Fingeraft
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."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Samlet"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Samlet modtaget data for hver interface"
@@ -1266,6 +1293,7 @@ msgstr "Oppetid"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Forbrug"
@@ -1291,6 +1319,7 @@ msgstr "Værdi"
msgid "View"
msgstr "Vis"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Se mere"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-25 21:09\n"
"PO-Revision-Date: 2025-10-28 22:59\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "YAML kopieren"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU-Kerne"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU-Zeit-Aufschlüsselung"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU-Auslastung"
@@ -597,7 +606,7 @@ msgstr "Fingerabdruck"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr "Firm­ware"
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -833,6 +842,10 @@ msgstr "Menü öffnen"
msgid "Or continue with"
msgstr "Oder fortfahren mit"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Andere"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Bestehende Warnungen überschreiben"
@@ -881,6 +894,15 @@ msgstr "Pausiert"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausiert ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Durchschnittliche Auslastung pro Kern"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Prozentsatz der Zeit in jedem Zustand"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Bitte <0>konfiguriere einen SMTP-Server</0>, um sicherzustellen, dass Warnungen zugestellt werden."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
@@ -1266,6 +1293,7 @@ msgstr "Betriebszeit"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Nutzung"
@@ -1291,6 +1319,7 @@ msgstr "Wert"
msgid "View"
msgstr "Ansicht"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Mehr anzeigen"

View File

@@ -365,8 +365,17 @@ msgstr "Copy YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU Cores"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU Time Breakdown"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU Usage"
@@ -828,6 +837,10 @@ msgstr "Open menu"
msgid "Or continue with"
msgstr "Or continue with"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Other"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Overwrite existing alerts"
@@ -876,6 +889,15 @@ msgstr "Paused"
msgid "Paused ({pausedSystemsLength})"
msgstr "Paused ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Per-core average utilization"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Percentage of time spent in each state"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -1172,6 +1194,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Total data received for each interface"
@@ -1261,6 +1288,7 @@ msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Usage"
@@ -1286,6 +1314,7 @@ msgstr "Value"
msgid "View"
msgstr "View"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "View more"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-25 21:09\n"
"PO-Revision-Date: 2025-11-04 22:13\n"
"Last-Translator: \n"
"Language-Team: Spanish\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/layout.tsx
msgid "Alert History"
msgstr "Historial de Alertas"
msgstr "Historial de alertas"
#: src/components/alerts/alert-button.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
msgid "All Systems"
msgstr "Todos los Sistemas"
msgstr "Todos los sistemas"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Are you sure you want to delete {name}?"
@@ -370,8 +370,17 @@ msgstr "Copiar YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Núcleos de CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Desglose de tiempo de CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Uso de CPU"
@@ -737,7 +746,7 @@ msgstr "Instrucciones manuales de configuración"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
msgid "Max 1 min"
msgstr "Máx 1 min"
msgstr "Máx. 1 min"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -747,11 +756,11 @@ msgstr "Memoria"
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Uso de Memoria"
msgstr "Uso de memoria"
#: src/components/routes/system.tsx
msgid "Memory usage of docker containers"
msgstr "Uso de memoria de los contenedores de Docker"
msgstr "Uso de memoria de los contenedores Docker"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
@@ -770,7 +779,7 @@ msgstr "Red"
#: src/components/routes/system.tsx
msgid "Network traffic of docker containers"
msgstr "Tráfico de red de los contenedores de Docker"
msgstr "Tráfico de red de los contenedores Docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -833,6 +842,10 @@ msgstr "Abrir menú"
msgid "Or continue with"
msgstr "O continuar con"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Otro"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Sobrescribir alertas existentes"
@@ -881,38 +894,47 @@ msgstr "Pausado"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausado ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Uso promedio por núcleo"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Porcentaje de tiempo dedicado a cada estado"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Por favor, <0>configure un servidor SMTP</0> para asegurar que las alertas sean entregadas."
msgstr "Por favor, <0>configura un servidor SMTP</0> para asegurar que las alertas sean entregadas."
#: src/components/alerts/alerts-sheet.tsx
msgid "Please check logs for more details."
msgstr "Por favor, revise los registros para más detalles."
msgstr "Por favor, revisa los registros para más detalles."
#: src/components/login/auth-form.tsx
#: src/components/login/forgot-pass-form.tsx
msgid "Please check your credentials and try again"
msgstr "Por favor, verifique sus credenciales e intente de nuevo"
msgstr "Por favor, verifica tus credenciales e inténtalo de nuevo"
#: src/components/login/login.tsx
msgid "Please create an admin account"
msgstr "Por favor, cree una cuenta de administrador"
msgstr "Por favor, crea una cuenta de administrador"
#: src/components/login/auth-form.tsx
msgid "Please enable pop-ups for this site"
msgstr "Por favor, habilite las ventanas emergentes para este sitio"
msgstr "Por favor, habilita las ventanas emergentes para este sitio"
#: src/lib/api.ts
msgid "Please log in again"
msgstr "Por favor, inicie sesión de nuevo"
msgstr "Por favor, inicia sesión de nuevo"
#: src/components/login/auth-form.tsx
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Por favor, consulte <0>la documentación</0> para obtener instrucciones."
msgstr "Por favor, consulta <0>la documentación</0> para obtener instrucciones."
#: src/components/login/login.tsx
msgid "Please sign in to your account"
msgstr "Por favor, inicie sesión en su cuenta"
msgstr "Por favor, inicia sesión en tu cuenta"
#: src/components/add-system.tsx
msgid "Port"
@@ -930,12 +952,12 @@ msgstr "Utilización precisa en el momento registrado"
#: src/components/routes/settings/general.tsx
msgid "Preferred Language"
msgstr "Idioma Preferido"
msgstr "Idioma preferido"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx
msgid "Public Key"
msgstr "Clave Pública"
msgstr "Clave pública"
#. Disk read
#: src/components/routes/system.tsx
@@ -962,7 +984,7 @@ msgstr "Solicitar OTP"
#: src/components/login/forgot-pass-form.tsx
msgid "Reset Password"
msgstr "Restablecer Contraseña"
msgstr "Restablecer contraseña"
#: src/components/alerts-history-columns.tsx
#: src/components/alerts-history-columns.tsx
@@ -992,16 +1014,16 @@ msgstr "Autoprueba S.M.A.R.T."
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Guarde la dirección usando la tecla enter o coma. Deje en blanco para desactivar las notificaciones por correo."
msgstr "Guarda la dirección usando la tecla enter o coma. Deja en blanco para desactivar las notificaciones por correo."
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/notifications.tsx
msgid "Save Settings"
msgstr "Guardar Configuración"
msgstr "Guardar configuración"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Guardar Sistema"
msgstr "Guardar sistema"
#: src/components/navbar.tsx
msgid "Search"
@@ -1013,7 +1035,7 @@ msgstr "Buscar sistemas o configuraciones..."
#: src/components/alerts/alerts-sheet.tsx
msgid "See <0>notification settings</0> to configure how you receive alerts."
msgstr "Consulte <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
msgstr "Consulta <0>configuración de notificaciones</0> para configurar cómo recibe alertas."
#: src/components/routes/system.tsx
msgid "Sent"
@@ -1068,7 +1090,7 @@ msgstr "Espacio de swap utilizado por el sistema"
#: src/components/routes/system.tsx
msgid "Swap Usage"
msgstr "Uso de Swap"
msgstr "Uso de swap"
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
@@ -1088,7 +1110,7 @@ msgstr "Sistemas"
#: src/components/routes/settings/config-yaml.tsx
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 su directorio de datos."
msgstr "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dentro de tu directorio de datos."
#: src/components/systems-table/systems-table.tsx
msgid "Table"
@@ -1123,7 +1145,7 @@ msgstr "Notificación de prueba enviada"
#: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Luego inicie sesión en el backend y restablezca la contraseña de su cuenta de usuario en la tabla de usuarios."
msgstr "Luego inicia sesión en el backend y restablece la contraseña de tu cuenta de usuario en la tabla de usuarios."
#: 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."
@@ -1167,7 +1189,7 @@ msgstr "Token"
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Tokens & Fingerprints"
msgstr "Tokens y Huellas Digitales"
msgstr "Tokens y huellas digitales"
#: 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."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Datos totales recibidos por cada interfaz"
@@ -1266,6 +1293,7 @@ msgstr "Tiempo de actividad"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Uso"
@@ -1291,13 +1319,14 @@ msgstr "Valor"
msgid "View"
msgstr "Vista"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Ver más"
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts."
msgstr "Ver sus 200 alertas más recientes."
msgstr "Ver tus 200 alertas más recientes."
#: src/components/systems-table/systems-table.tsx
msgid "Visible Fields"
@@ -1309,7 +1338,7 @@ msgstr "Esperando suficientes registros para mostrar"
#: src/components/routes/settings/general.tsx
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
msgstr "¿Quieres ayudarnos a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
msgstr "¿Quieres ayudar a mejorar nuestras traducciones? Consulta <0>Crowdin</0> para más detalles."
#: src/components/routes/settings/general.tsx
msgid "Warning (%)"
@@ -1349,4 +1378,4 @@ msgstr "Configuración YAML"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Su configuración de usuario ha sido actualizada."
msgstr "Tu configuración de usuario ha sido actualizada."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fa\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "کپی YAML"
msgid "CPU"
msgstr "پردازنده"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "هسته‌های CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "تجزیه زمان CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "میزان استفاده از پردازنده"
@@ -833,6 +842,10 @@ msgstr "باز کردن منو"
msgid "Or continue with"
msgstr "یا ادامه با"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "سایر"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "بازنویسی هشدارهای موجود"
@@ -881,6 +894,15 @@ msgstr "مکث شده"
msgid "Paused ({pausedSystemsLength})"
msgstr "مکث شده ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "میانگین استفاده در هر هسته"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "درصد زمان صرف شده در هر حالت"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "لطفاً برای اطمینان از تحویل هشدارها، یک <0>سرور SMTP پیکربندی کنید</0>."
@@ -1177,6 +1199,11 @@ msgstr "توکن‌ها به عامل‌ها اجازه اتصال و ثبت‌
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "توکن‌ها و اثرات انگشت برای احراز هویت اتصالات WebSocket به هاب استفاده می‌شوند."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "کل"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "داده‌های کل دریافت شده برای هر رابط"
@@ -1266,6 +1293,7 @@ msgstr "آپتایم"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "میزان استفاده"
@@ -1291,6 +1319,7 @@ msgstr "مقدار"
msgid "View"
msgstr "مشاهده"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "مشاهده بیشتر"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-25 20:53\n"
"PO-Revision-Date: 2025-10-28 22:59\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -370,8 +370,17 @@ msgstr "Copier YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Cœurs CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Répartition du temps CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Utilisation du CPU"
@@ -405,7 +414,7 @@ msgstr "État actuel"
#. Power Cycles
#: src/components/routes/system/smart-table.tsx
msgid "Cycles"
msgstr "Cycles"
msgstr ""
#: src/components/command-palette.tsx
msgid "Dashboard"
@@ -833,6 +842,10 @@ msgstr "Ouvrir le menu"
msgid "Or continue with"
msgstr "Ou continuer avec"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Autre"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Écraser les alertes existantes"
@@ -881,6 +894,15 @@ msgstr "En pause"
msgid "Paused ({pausedSystemsLength})"
msgstr "Mis en pause ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Utilisation moyenne par cœur"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Pourcentage de temps passé dans chaque état"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Veuillez <0>configurer un serveur SMTP</0> pour garantir la livraison des alertes."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Données totales reçues pour chaque interface"
@@ -1223,7 +1250,7 @@ msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
#: src/components/routes/system/smart-table.tsx
msgid "Type"
msgstr "Type"
msgstr ""
#. Temperature / network units
#: src/components/routes/settings/general.tsx
@@ -1266,6 +1293,7 @@ msgstr "Temps de fonctionnement"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Utilisation"
@@ -1291,6 +1319,7 @@ msgstr "Valeur"
msgid "View"
msgstr "Vue"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Voir plus"

View File

@@ -1,17 +1,22 @@
msgid ""
msgstr ""
"POT-Creation-Date: 2025-10-30 16:35-0400\n"
"POT-Creation-Date: 2024-11-01 11:30-0400\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: he\n"
"Project-Id-Version: \n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-30 20:47\n"
"PO-Revision-Date: 2025-10-30 21:53\n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
"Language-Team: Hebrew\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
"X-Crowdin-Project: beszel\n"
"X-Crowdin-Project-ID: 733311\n"
"X-Crowdin-Language: he\n"
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
@@ -170,7 +175,7 @@ msgstr "צריכת חשמל ממוצעת של GPUs"
#: src/components/routes/system.tsx
msgid "Average system-wide CPU utilization"
msgstr "ניצול ממוצע של CPU ברמת המערכת"
msgstr "ניצול ממוצע כלל-מערכתי של CPU"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
@@ -215,7 +220,7 @@ msgstr "ביטים (Kbps, Mbps, Gbps)"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "בייטים (KB/s, MB/s, GB/s)"
msgstr "בתים (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers"
@@ -265,7 +270,7 @@ msgstr "בדוק את {email} לקישור איפוס."
#: src/components/routes/settings/layout.tsx
msgid "Check logs for more details."
msgstr "בדוק יומנים לפרטים נוספים."
msgstr "בדוק לוגים לפרטים נוספים"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
@@ -294,7 +299,7 @@ msgstr "הוראות שורת פקודה"
#: src/components/routes/settings/notifications.tsx
msgid "Configure how you receive alert notifications."
msgstr "הגדר כיצד אתה מקבל התראות."
msgstr "הגדר כיצד לקבל התראות."
#: src/components/login/auth-form.tsx
#: src/components/login/auth-form.tsx
@@ -365,8 +370,17 @@ msgstr "העתק YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "ליבות CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "פירוט זמן CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "שימוש CPU"
@@ -464,7 +478,7 @@ msgstr "שימוש זיכרון של Docker"
#: src/components/routes/system.tsx
msgid "Docker Network I/O"
msgstr "רשת Docker I/O"
msgstr "I/O של רשת Docker"
#: src/components/command-palette.tsx
msgid "Documentation"
@@ -484,7 +498,7 @@ msgstr "כבוי ({downSystemsLength})"
#: src/components/routes/system/network-sheet.tsx
msgid "Download"
msgstr "הורד"
msgstr "הורדה"
#: src/components/alerts-history-columns.tsx
msgid "Duration"
@@ -535,11 +549,11 @@ msgstr "שגיאה"
#. placeholder {2}: alert.min
#: src/components/active-alerts.tsx
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "עולה על {0}{1} ב-{2, plural, one {# דקה} other {# דקות}} האחרונות"
msgstr "עלה על {0}{1} ב{2, plural, one {דקה האחרונה} other {-# הדקות האחרונות}}"
#: src/components/routes/settings/config-yaml.tsx
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
msgstr "מערכות קיימות שלא מוגדרות ב-<0>config.yml</0> יימחקו. אנא בצע גיבויים קבועים."
msgstr "מערכות קיימות שלא מוגדרות ב-<0>config.yml</0> יימחקו. אנא בצע גיבויים באופן קבוע."
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "Export"
@@ -828,6 +842,10 @@ msgstr "פתח תפריט"
msgid "Or continue with"
msgstr "או המשך עם"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "אחר"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "דרוס התראות קיימות"
@@ -876,6 +894,15 @@ msgstr "מושהה"
msgid "Paused ({pausedSystemsLength})"
msgstr "מושהה ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "ניצול ממוצע לליבה"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "אחוז הזמן המוקדש לכל מצב"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "אנא <0>הגדר שרת SMTP</0> כדי להבטיח שהתראות יישלחו."
@@ -1172,6 +1199,11 @@ msgstr "Tokens מאפשרים לסוכנים להתחבר ולהירשם. טבי
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the 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
msgid "Total data received for each interface"
msgstr "סך נתונים שהתקבלו עבור כל ממשק"
@@ -1251,7 +1283,7 @@ msgstr "עודכן"
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
msgstr "העלה"
msgstr "העלאה"
#: src/components/routes/system.tsx
msgid "Uptime"
@@ -1261,6 +1293,7 @@ msgstr "זמן פעילות"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "שימוש"
@@ -1286,9 +1319,10 @@ msgstr "ערך"
msgid "View"
msgstr "צפה"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "צפה עוד"
msgstr "צפה בעוד"
#: src/components/routes/settings/alerts-history-data-table.tsx
msgid "View your 200 most recent alerts."
@@ -1345,4 +1379,3 @@ msgstr "תצורת YAML"
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "הגדרות המשתמש שלך עודכנו."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: hr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Croatian\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
@@ -370,8 +370,17 @@ msgstr "Kopiraj YAML"
msgid "CPU"
msgstr "Procesor"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU jezgre"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Raspodjela CPU vremena"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Iskorištenost procesora"
@@ -597,7 +606,7 @@ msgstr "Otisak prsta"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -755,7 +764,7 @@ msgstr "Upotreba memorije Docker spremnika"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Otvori menu"
msgid "Or continue with"
msgstr "Ili nastavi sa"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Ostalo"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Prebrišite postojeća upozorenja"
@@ -881,6 +894,15 @@ msgstr "Pauzirano"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pauzirano ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Prosječna iskorištenost po jezgri"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Postotak vremena provedenog u svakom stanju"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Molimo <0>konfigurirajte SMTP server</0> kako biste osigurali isporuku upozorenja."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Ukupni podaci primljeni za svako sučelje"
@@ -1266,6 +1293,7 @@ msgstr "Vrijeme rada"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Iskorištenost"
@@ -1291,6 +1319,7 @@ msgstr "Vrijednost"
msgid "View"
msgstr "Prikaz"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Prikaži više"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: hu\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 22:59\n"
"Last-Translator: \n"
"Language-Team: Hungarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "YAML másolása"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU magok"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU idő felbontása"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU használat"
@@ -597,7 +606,7 @@ msgstr "Ujjlenyomat"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -833,6 +842,10 @@ msgstr "Menü megnyitása"
msgid "Or continue with"
msgstr "Vagy folytasd ezzel"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Egyéb"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Felülírja a meglévő riasztásokat"
@@ -881,6 +894,15 @@ msgstr "Szüneteltetve"
msgid "Paused ({pausedSystemsLength})"
msgstr "Szüneteltetve ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Átlagos kihasználtság magonként"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Az idő százalékos aránya minden állapotban"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Kérjük, <0>konfigurálj egy SMTP szervert</0> az értesítések kézbesítésének biztosítása érdekében."
@@ -1177,6 +1199,11 @@ msgstr ""
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr ""
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Összesen"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Összes fogadott adat minden interfészenként"
@@ -1266,6 +1293,7 @@ msgstr "Üzemidő"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Használat"
@@ -1291,6 +1319,7 @@ msgstr "Érték"
msgid "View"
msgstr "Nézet"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Továbbiak megjelenítése"

File diff suppressed because it is too large Load Diff

View File

@@ -778,7 +778,6 @@ msgstr "Net traffík docker kerfa"
#: src/components/routes/system.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "Network traffic of public interfaces"
msgstr ""
@@ -921,11 +920,6 @@ msgstr "Vinsamlegast skráðu þig inn á aðganginn þinn"
msgid "Port"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
msgid "Precise utilization at the recorded time"
@@ -950,11 +944,6 @@ msgstr "Lesa"
msgid "Received"
msgstr "Móttekið"
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
msgid "Refresh"
msgstr ""
#: src/components/login/login.tsx
msgid "Request a one-time password"
msgstr ""
@@ -1022,10 +1011,6 @@ msgstr ""
msgid "Sent"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Serial Number"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors."
msgstr "Stilltu prósentuþröskuld fyrir mælaliti."
@@ -1253,10 +1238,6 @@ msgstr ""
msgid "Up ({upSystemsLength})"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgid "Updated"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Upload"
msgstr ""
@@ -1353,3 +1334,4 @@ msgstr ""
#: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated."
msgstr "Notenda stillingar vistaðar."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-30 21:53\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "Copia YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Core CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Suddivisione tempo CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Utilizzo CPU"
@@ -833,6 +842,10 @@ msgstr "Apri menu"
msgid "Or continue with"
msgstr "Oppure continua con"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Altro"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Sovrascrivi avvisi esistenti"
@@ -881,6 +894,15 @@ msgstr "In pausa"
msgid "Paused ({pausedSystemsLength})"
msgstr "In pausa ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Utilizzo medio per core"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Percentuale di tempo trascorso in ogni stato"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Si prega di <0>configurare un server SMTP</0> per garantire la consegna degli avvisi."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Dati totali ricevuti per ogni interfaccia"
@@ -1266,6 +1293,7 @@ msgstr "Tempo di attività"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Utilizzo"
@@ -1291,6 +1319,7 @@ msgstr "Valore"
msgid "View"
msgstr "Vista"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Visualizza altro"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -370,8 +370,17 @@ msgstr "YAMLをコピー"
msgid "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU コア"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU 時間の内訳"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU使用率"
@@ -833,6 +842,10 @@ msgstr "メニューを開く"
msgid "Or continue with"
msgstr "または、以下の方法でログイン"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "その他"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "既存のアラートを上書き"
@@ -881,6 +894,15 @@ msgstr "一時停止中"
msgid "Paused ({pausedSystemsLength})"
msgstr "一時停止 ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "コアごとの平均使用率"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "各状態で費やした時間の割合"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "アラートが配信されるように<0>SMTPサーバーを設定</0>してください。"
@@ -1177,6 +1199,11 @@ msgstr "トークンはエージェントの接続と登録を可能にします
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "トークンとフィンガープリントは、ハブへのWebSocket接続の認証に使用されます。"
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "総数"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "各インターフェースの総受信データ量"
@@ -1266,6 +1293,7 @@ msgstr "稼働時間"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "使用量"
@@ -1291,6 +1319,7 @@ msgstr "値"
msgid "View"
msgstr "表示"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "もっと見る"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ko\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -370,8 +370,17 @@ msgstr "YAML 복사"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU 코어"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU 시간 분배"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU 사용량"
@@ -833,6 +842,10 @@ msgstr "메뉴 열기"
msgid "Or continue with"
msgstr "또는 아래 항목으로 진행하기"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "기타"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "기존 알림 덮어쓰기"
@@ -881,6 +894,15 @@ msgstr "일시 정지됨"
msgid "Paused ({pausedSystemsLength})"
msgstr "일시 정지됨 ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "코어별 평균 사용률"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "각 상태에서 보낸 시간의 백분율"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "알림이 전달되도록 <0>SMTP 서버를 구성</0>하세요."
@@ -1177,6 +1199,11 @@ msgstr "토큰은 에이전트가 연결하고 등록할 수 있도록 합니다
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "토큰과 지문은 허브에 대한 WebSocket 연결을 인증하는 데 사용됩니다."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "총"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "각 인터페이스별 총합 다운로드 데이터량"
@@ -1266,6 +1293,7 @@ msgstr "가동 시간"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "사용량"
@@ -1291,6 +1319,7 @@ msgstr "값"
msgid "View"
msgstr "보기"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "더 보기"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 22:59\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "YAML kopiëren"
msgid "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU-kernen"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU-tijdverdeling"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Processorgebruik"
@@ -597,7 +606,7 @@ msgstr "Vingerafdruk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -755,7 +764,7 @@ msgstr "Geheugengebruik van docker containers"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr ""
msgid "Or continue with"
msgstr "Of ga verder met"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Overig"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Overschrijf bestaande waarschuwingen"
@@ -881,6 +894,15 @@ msgstr "Gepauzeerd"
msgid "Paused ({pausedSystemsLength})"
msgstr "Gepauzeerd ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Gemiddeld gebruik per kern"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Percentage tijd besteed in elke status"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "<0>Configureer een SMTP-server </0> om ervoor te zorgen dat waarschuwingen worden afgeleverd."
@@ -984,7 +1006,7 @@ msgstr "Rijen per pagina"
#: src/components/routes/system/smart-table.tsx
msgid "S.M.A.R.T. Details"
msgstr "S.M.A.R.T. Details"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "S.M.A.R.T. Self-Test"
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Totaal ontvangen gegevens per interface"
@@ -1223,7 +1250,7 @@ msgstr "Triggert wanneer het gebruik van een schijf een drempelwaarde overschrij
#: src/components/routes/system/smart-table.tsx
msgid "Type"
msgstr "Type"
msgstr ""
#. Temperature / network units
#: src/components/routes/settings/general.tsx
@@ -1266,6 +1293,7 @@ msgstr "Actief"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Gebruik"
@@ -1291,6 +1319,7 @@ msgstr "Waarde"
msgid "View"
msgstr "Weergave"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Meer weergeven"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: no\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-22 10:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Norwegian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -370,8 +370,17 @@ msgstr "Kopier YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU-kjerner"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU-tidsoppdeling"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU-bruk"
@@ -833,6 +842,10 @@ msgstr "Åpne meny"
msgid "Or continue with"
msgstr "Eller fortsett med"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Andre"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Overskriv eksisterende alarmer"
@@ -881,6 +894,15 @@ msgstr "Satt på Pause"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pauset ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Gjennomsnittlig utnyttelse per kjerne"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Prosentandel av tid brukt i hver tilstand"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Vennligst <0>konfigurer en SMTP-server</0> for å forsikre deg om at varsler blir levert."
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Totalt mottatt data for hvert grensesnitt"
@@ -1223,7 +1250,7 @@ msgstr "Slår inn når forbruk av hvilken som helst disk overstiger en grensever
#: src/components/routes/system/smart-table.tsx
msgid "Type"
msgstr "Type"
msgstr ""
#. Temperature / network units
#: src/components/routes/settings/general.tsx
@@ -1266,6 +1293,7 @@ msgstr "Oppetid"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Forbruk"
@@ -1291,6 +1319,7 @@ msgstr "Verdi"
msgid "View"
msgstr "Visning"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Se mer"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Polish\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
@@ -370,8 +370,17 @@ msgstr "Kopiuj YAML"
msgid "CPU"
msgstr "Procesor"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Rdzenie CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Podział czasu CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Użycie procesora"
@@ -833,6 +842,10 @@ msgstr "Otwórz menu"
msgid "Or continue with"
msgstr "Lub kontynuuj z"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Inne"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Nadpisz istniejące alerty"
@@ -881,6 +894,15 @@ msgstr "Wstrzymane"
msgid "Paused ({pausedSystemsLength})"
msgstr "Wstrzymane ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Średnie wykorzystanie na rdzeń"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Procent czasu spędzonego w każdym stanie"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Proszę <0>skonfigurować serwer SMTP</0>, aby zapewnić dostarczanie powiadomień."
@@ -1177,6 +1199,11 @@ msgstr "Tokeny umożliwiają agentom łączenie się i rejestrację. Odciski pal
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."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Łącznie"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Całkowita ilość danych odebranych dla każdego interfejsu"
@@ -1266,6 +1293,7 @@ msgstr "Czas pracy"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Wykorzystanie"
@@ -1291,6 +1319,7 @@ msgstr "Wartość"
msgid "View"
msgstr "Widok"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Zobacz więcej"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: pt\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-11-04 22:13\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -134,7 +134,7 @@ msgstr "Alertas"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Todos os contentores"
msgstr "Todos os Contêineres"
#: src/components/alerts/alerts-sheet.tsx
#: src/components/command-palette.tsx
@@ -224,7 +224,7 @@ msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
msgid "Cache / Buffers"
msgstr "Cache / Buffers"
msgstr ""
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -370,8 +370,17 @@ msgstr "Copiar YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Núcleos de CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Distribuição do Tempo de CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Uso de CPU"
@@ -671,7 +680,7 @@ msgstr "Endereço de email inválido."
#. Linux kernel
#: src/components/routes/system.tsx
msgid "Kernel"
msgstr "Kernel"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Language"
@@ -719,7 +728,7 @@ msgstr "Tentativa de login falhou"
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
msgid "Logs"
msgstr "Logs"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
@@ -755,7 +764,7 @@ msgstr "Uso de memória dos contêineres Docker"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
msgstr "Modelo"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Abrir menu"
msgid "Or continue with"
msgstr "Ou continue com"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Outro"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Sobrescrever alertas existentes"
@@ -881,6 +894,15 @@ msgstr "Pausado"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausado ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Utilização média por núcleo"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Percentagem de tempo gasto em cada estado"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Por favor, <0>configure um servidor SMTP</0> para garantir que os alertas sejam entregues."
@@ -1098,7 +1120,7 @@ msgstr "Tabela"
#: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Temp"
msgstr "Temp"
msgstr ""
#: src/components/routes/system.tsx
#: src/lib/alerts.ts
@@ -1161,7 +1183,7 @@ msgstr "Alternar tema"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Dados totais recebidos para cada interface"
@@ -1266,6 +1293,7 @@ msgstr "Tempo de Atividade"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Uso"
@@ -1291,6 +1319,7 @@ msgstr "Valor"
msgid "View"
msgstr "Visual"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Ver mais"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ro\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Romanian\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n"
@@ -18,21 +18,6 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx
msgid "{0, plural, one {# day} other {# days}}"
msgstr ""
#. placeholder {0}: Math.trunc(system.info.u / 3600)
#: src/components/routes/system.tsx
msgid "{0, plural, one {# hour} other {# hours}}"
msgstr ""
#. placeholder {0}: Math.trunc(system.info.u / 60)
#: src/components/routes/system.tsx
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
msgstr ""
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -234,6 +219,10 @@ msgstr ""
msgid "Cancel"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Capacity"
msgstr ""
#: src/components/routes/settings/config-yaml.tsx
msgid "Caution - potential data loss"
msgstr ""
@@ -279,6 +268,10 @@ msgstr ""
msgid "Click on a container to view more information."
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Click on a device to view more information."
msgstr ""
#: src/components/systems-table/systems-table.tsx
msgid "Click on a system to view more information."
msgstr ""
@@ -397,6 +390,11 @@ msgstr ""
msgid "Current state"
msgstr ""
#. Power Cycles
#: src/components/routes/system/smart-table.tsx
msgid "Cycles"
msgstr ""
#: src/components/command-palette.tsx
msgid "Dashboard"
msgstr ""
@@ -418,6 +416,10 @@ msgstr ""
msgid "Detail"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Device"
msgstr ""
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Discharging"
@@ -548,6 +550,10 @@ msgstr ""
msgid "Fahrenheit (°F)"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Failed Attributes:"
msgstr ""
#: src/lib/api.ts
msgid "Failed to authenticate"
msgstr ""
@@ -568,6 +574,7 @@ msgstr ""
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr ""
@@ -576,6 +583,10 @@ msgstr ""
msgid "Fingerprint"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr ""
@@ -730,6 +741,10 @@ msgstr ""
msgid "Memory usage of docker containers"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
@@ -761,11 +776,18 @@ msgstr ""
msgid "No results found."
msgstr ""
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system/smart-table.tsx
msgid "No results."
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "No S.M.A.R.T. attributes available for this device."
msgstr ""
#: src/components/systems-table/systems-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "No systems found."
@@ -884,6 +906,11 @@ msgstr ""
msgid "Port"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
msgid "Precise utilization at the recorded time"
@@ -943,6 +970,14 @@ msgstr ""
msgid "Rows per page"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "S.M.A.R.T. Details"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "S.M.A.R.T. Self-Test"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr ""
@@ -972,6 +1007,10 @@ msgstr ""
msgid "Sent"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Serial Number"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Set percentage thresholds for meter colors."
msgstr ""
@@ -1005,6 +1044,7 @@ msgid "State"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
@@ -1043,6 +1083,7 @@ msgid "Table"
msgstr ""
#. Temperature label in systems table
#: src/components/routes/system/smart-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Temp"
msgstr ""
@@ -1168,6 +1209,10 @@ msgstr ""
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Type"
msgstr ""
#. Temperature / network units
#: src/components/routes/settings/general.tsx
msgid "Unit preferences"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ru\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -370,8 +370,17 @@ msgstr "Скопировать YAML"
msgid "CPU"
msgstr "ЦП"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Ядра ЦП"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Распределение времени ЦП"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Использование CPU"
@@ -833,6 +842,10 @@ msgstr "Открыть меню"
msgid "Or continue with"
msgstr "Или продолжить с"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Другое"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Перезаписать существующие оповещения"
@@ -881,6 +894,15 @@ msgstr "Пауза"
msgid "Paused ({pausedSystemsLength})"
msgstr "Пауза ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Среднее использование на ядро"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Процент времени, проведенного в каждом состоянии"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Пожалуйста, <0>настройте SMTP-сервер</0>, чтобы гарантировать доставку оповещений."
@@ -1177,6 +1199,11 @@ msgstr "Токены позволяют агентам подключаться
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Токены и отпечатки используются для аутентификации соединений WebSocket с хабом."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Итого"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Общий объем полученных данных для каждого интерфейса"
@@ -1266,6 +1293,7 @@ msgstr "Время работы"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Использование"
@@ -1291,6 +1319,7 @@ msgstr "Значение"
msgid "View"
msgstr "Вид"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Показать больше"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sl\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
@@ -117,7 +117,7 @@ msgstr "Administrator"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -370,8 +370,17 @@ msgstr ""
msgid "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU jedra"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Razčlenitev časa CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU poraba"
@@ -755,7 +764,7 @@ msgstr "Poraba pomnilnika docker kontejnerjev"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Odpri menu"
msgid "Or continue with"
msgstr "Ali nadaljuj z"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Drugo"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Prepiši obstoječe alarme"
@@ -881,6 +894,15 @@ msgstr "Zaustavljeno"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pavzirano za {pausedSystemsLength}"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Povprečna izkoriščenost na jedro"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Odstotek časa, preživetega v vsakem stanju"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "<0>Nastavite strežnik SMTP</0>, da zagotovite dostavo opozoril."
@@ -1060,7 +1082,7 @@ msgstr "Stanje"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr "Status"
msgstr ""
#: src/components/routes/system.tsx
msgid "Swap space used by the system"
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Skupni prejeti podatki za vsak vmesnik"
@@ -1266,6 +1293,7 @@ msgstr "Čas delovanja"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Uporaba"
@@ -1291,6 +1319,7 @@ msgstr "Vrednost"
msgid "View"
msgstr "Pogled"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Prikaži več"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: sv\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -43,7 +43,7 @@ msgstr "1 timme"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
@@ -60,7 +60,7 @@ msgstr "12 timmar"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
msgstr ""
#: src/lib/utils.ts
msgid "24 hours"
@@ -73,7 +73,7 @@ msgstr "30 dagar"
#. Load average
#: src/components/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
msgstr ""
#. Table column
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -113,11 +113,11 @@ msgstr "Justera visningsalternativ för diagram."
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
msgid "Admin"
msgstr "Admin"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -241,7 +241,7 @@ msgstr "Varning - potentiell dataförlust"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -368,10 +368,19 @@ msgstr "Kopiera YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU-kärnor"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU-tidsuppdelning"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU-användning"
@@ -439,7 +448,7 @@ msgstr "Urladdar"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr ""
#: src/components/routes/system.tsx
msgid "Disk I/O"
@@ -616,7 +625,7 @@ msgstr "FreeBSD kommando"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Full"
msgstr "Full"
msgstr ""
#. Context: General settings
#: src/components/routes/settings/general.tsx
@@ -679,7 +688,7 @@ msgstr "Språk"
#: src/components/systems-table/systems-table.tsx
msgid "Layout"
msgstr "Layout"
msgstr ""
#: src/components/routes/system.tsx
msgid "Load Average"
@@ -737,7 +746,7 @@ msgstr "Manuella installationsinstruktioner"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
msgid "Max 1 min"
msgstr "Max 1 min"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Öppna menyn"
msgid "Or continue with"
msgstr "Eller fortsätt med"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Annat"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Skriv över befintliga larm"
@@ -881,6 +894,15 @@ msgstr "Pausad"
msgid "Paused ({pausedSystemsLength})"
msgstr "Pausad ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Genomsnittlig användning per kärna"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Procentandel av tid spenderad i varje tillstånd"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Vänligen <0>konfigurera en SMTP-server</0> för att säkerställa att larm levereras."
@@ -916,7 +938,7 @@ msgstr "Vänligen logga in på ditt konto"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1060,7 +1082,7 @@ msgstr "Tillstånd"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr "Status"
msgstr ""
#: src/components/routes/system.tsx
msgid "Swap space used by the system"
@@ -1076,7 +1098,7 @@ msgstr "Swap-användning"
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "System"
msgstr "System"
msgstr ""
#: src/components/routes/system.tsx
msgid "System load averages over time"
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Totalt mottagen data för varje gränssnitt"
@@ -1266,6 +1293,7 @@ msgstr "Drifttid"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Användning"
@@ -1291,6 +1319,7 @@ msgstr "Värde"
msgid "View"
msgstr "Visa"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Visa mer"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: tr\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -368,10 +368,19 @@ msgstr "YAML'ı kopyala"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU Çekirdekleri"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU Zaman Dağılımı"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU Kullanımı"
@@ -439,7 +448,7 @@ msgstr "Boşalıyor"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr ""
#: src/components/routes/system.tsx
msgid "Disk I/O"
@@ -755,7 +764,7 @@ msgstr "Docker konteynerlerinin bellek kullanımı"
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -833,6 +842,10 @@ msgstr "Menüyü aç"
msgid "Or continue with"
msgstr "Veya devam et"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Diğer"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Mevcut uyarıların üzerine yaz"
@@ -881,6 +894,15 @@ msgstr "Duraklatıldı"
msgid "Paused ({pausedSystemsLength})"
msgstr "Duraklatıldı ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Çekirdek başına ortalama kullanım"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Her durumda harcanan zamanın yüzdesi"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Uyarıların teslim edilmesini sağlamak için lütfen bir SMTP sunucusu <0>yapılandırın</0>."
@@ -916,7 +938,7 @@ msgstr "Lütfen hesabınıza giriş yapın"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
@@ -1161,7 +1183,7 @@ msgstr "Temayı değiştir"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Her arayüz için alınan toplam veri"
@@ -1266,6 +1293,7 @@ msgstr "Çalışma Süresi"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Kullanım"
@@ -1291,6 +1319,7 @@ msgstr "Değer"
msgid "View"
msgstr "Görüntüle"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Daha fazla göster"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: uk\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
@@ -370,8 +370,17 @@ msgstr "Копіювати YAML"
msgid "CPU"
msgstr "ЦП"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Ядра ЦП"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Розподіл часу ЦП"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Використання ЦП"
@@ -833,6 +842,10 @@ msgstr "Відкрити меню"
msgid "Or continue with"
msgstr "Або продовжити з"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Інше"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Перезаписати існуючі сповіщення"
@@ -881,6 +894,15 @@ msgstr "Призупинено"
msgid "Paused ({pausedSystemsLength})"
msgstr "Призупинено ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Середнє використання на ядро"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Відсоток часу, проведеного в кожному стані"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Будь ласка, <0>налаштуйте SMTP сервер</0>, щоб забезпечити доставку сповіщень."
@@ -1177,6 +1199,11 @@ msgstr "Токени дозволяють агентам підключатис
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "Токени та відбитки використовуються для автентифікації WebSocket з'єднань до хабу."
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Разом"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "Загальний обсяг отриманих даних для кожного інтерфейсу"
@@ -1266,6 +1293,7 @@ msgstr "Час роботи"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Використання"
@@ -1291,6 +1319,7 @@ msgstr "Значення"
msgid "View"
msgstr "Вигляд"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Переглянути більше"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: vi\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Vietnamese\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -368,10 +368,19 @@ msgstr "Sao chép YAML"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "Nhân CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "Phân tích thời gian CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "Sử dụng CPU"
@@ -504,7 +513,7 @@ msgstr "Chỉnh sửa"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr "Email"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -833,6 +842,10 @@ msgstr "Mở menu"
msgid "Or continue with"
msgstr "Hoặc tiếp tục với"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "Khác"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "Ghi đè các cảnh báo hiện có"
@@ -881,6 +894,15 @@ msgstr "Đã tạm dừng"
msgid "Paused ({pausedSystemsLength})"
msgstr "Đã tạm dừng ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "Tỷ lệ sử dụng trung bình mỗi nhân"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Phần trăm thời gian dành cho mỗi trạng thái"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Vui lòng <0>cấu hình máy chủ SMTP</0> để đảm bảo cảnh báo được gửi đi."
@@ -1161,7 +1183,7 @@ msgstr "Chuyển đổi chủ đề"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1177,6 +1199,11 @@ 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."
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
msgid "Total data received for each interface"
msgstr "Tổng dữ liệu nhận được cho mỗi giao diện"
@@ -1266,6 +1293,7 @@ msgstr "Thời gian hoạt động"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "Sử dụng"
@@ -1291,6 +1319,7 @@ msgstr "Giá trị"
msgid "View"
msgstr "Xem"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Xem thêm"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-25 21:09\n"
"PO-Revision-Date: 2025-10-30 21:53\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -370,8 +370,17 @@ msgstr "复制 YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU 核心"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU 时间细分"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU 使用率"
@@ -833,6 +842,10 @@ msgstr "打开菜单"
msgid "Or continue with"
msgstr "或使用以下方式登录"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "其他"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "覆盖现有警报"
@@ -881,6 +894,15 @@ msgstr "已暂停"
msgid "Paused ({pausedSystemsLength})"
msgstr "已暂停 ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "每个核心的平均利用率"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "在每个状态下花费的时间百分比"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "请<0>配置 SMTP 服务器</0>以确保警报被传递。"
@@ -1177,6 +1199,11 @@ msgstr "令牌允许客户端连接和注册。指纹是每个系统唯一的稳
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌与指纹用于验证到中心的 WebSocket 连接。"
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "总计"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "每个接口的总接收数据量"
@@ -1266,6 +1293,7 @@ msgstr "正常运行时间"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "使用"
@@ -1291,6 +1319,7 @@ msgstr "值"
msgid "View"
msgstr "视图"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "查看更多"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-10-28 23:00\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional, Hong Kong\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -370,8 +370,17 @@ msgstr "複製YAML"
msgid "CPU"
msgstr "處理器"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU 核心"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU 時間細分"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU 使用率"
@@ -833,6 +842,10 @@ msgstr "開啟選單"
msgid "Or continue with"
msgstr "或繼續使用"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "其他"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "覆蓋現有警報"
@@ -881,6 +894,15 @@ msgstr "已暫停"
msgid "Paused ({pausedSystemsLength})"
msgstr "已暫停 ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "每個核心的平均使用率"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "在每個狀態下花費的時間百分比"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "請<0>配置SMTP伺服器</0>以確保警報被傳送。"
@@ -1177,6 +1199,11 @@ msgstr "令牌允許代理程式連接和註冊。指紋是每個系統唯一的
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋用於驗證到中心的WebSocket連接。"
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "總計"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "每個介面的總接收資料量"
@@ -1266,6 +1293,7 @@ msgstr "正常運行時間"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "使用"
@@ -1291,6 +1319,7 @@ msgstr "值"
msgid "View"
msgstr "檢視"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "查看更多"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2025-10-20 21:37\n"
"PO-Revision-Date: 2025-11-01 07:52\n"
"Last-Translator: \n"
"Language-Team: Chinese Traditional\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -26,15 +26,15 @@ msgstr "已選取 {1} 個項目中的 {0} 個"
#: src/lib/utils.ts
msgid "{count, plural, one {{countString} day} other {{countString} days}}"
msgstr ""
msgstr "{count, plural, one {{countString} 天} other {{countString} 天}}"
#: src/lib/utils.ts
msgid "{count, plural, one {{countString} hour} other {{countString} hours}}"
msgstr ""
msgstr "{count, plural, one {{countString} 小時} other {{countString} 小時}}"
#: src/lib/utils.ts
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
msgstr ""
msgstr "{count, plural, one {{countString} 分鐘} few {{countString} 分鐘} many {{countString} 分鐘} other {{countString} 分鐘}}"
#: src/lib/utils.ts
msgid "1 hour"
@@ -370,8 +370,17 @@ msgstr "複製YAML"
msgid "CPU"
msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
msgstr "CPU 核心"
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Time Breakdown"
msgstr "CPU 時間細分"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
msgstr "CPU 使用率"
@@ -833,6 +842,10 @@ msgstr "開啟選單"
msgid "Or continue with"
msgstr "或繼續使用"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Other"
msgstr "其他"
#: src/components/alerts/alerts-sheet.tsx
msgid "Overwrite existing alerts"
msgstr "覆蓋現有警報"
@@ -881,6 +894,15 @@ msgstr "已暫停"
msgid "Paused ({pausedSystemsLength})"
msgstr "已暫停 ({pausedSystemsLength})"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Per-core average utilization"
msgstr "每個核心的平均使用率"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "在每個狀態下花費的時間百分比"
#: src/components/routes/settings/notifications.tsx
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "請<0>設定一個SMTP 伺服器</0>以確保能傳送警報。"
@@ -1177,6 +1199,11 @@ msgstr "令牌允許代理程式連線和註冊。指紋是每個系統的唯一
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
msgstr "令牌和指紋被用於驗證到 Hub 的 WebSocket 連線。"
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "總計"
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
msgstr "每個介面的總接收資料量"
@@ -1266,6 +1293,7 @@ msgstr "運行時間"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/cpu-sheet.tsx
msgid "Usage"
msgstr "使用量"
@@ -1291,6 +1319,7 @@ msgstr "值"
msgid "View"
msgstr "檢視"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "查看更多"

View File

@@ -1,5 +1,5 @@
import type { RecordModel } from "pocketbase"
import type { Unit, Os, BatteryState, HourFormat, ConnectionType } from "@/lib/enums"
import type { Unit, Os, BatteryState, HourFormat, ConnectionType, ServiceStatus, ServiceSubState } from "@/lib/enums"
// global window properties
declare global {
@@ -77,6 +77,13 @@ export interface SystemInfo {
os?: Os
/** connection type */
ct?: ConnectionType
/** extra filesystem percentages */
efsp?: Record<string, ExtraFsInfo>
}
export interface ExtraFsInfo {
/** disk percent */
dp: number
}
export interface SystemStats {
@@ -84,6 +91,10 @@ export interface SystemStats {
cpu: number
/** peak cpu */
cpum?: number
/** cpu breakdown [user, system, iowait, steal, idle] (0-100 integers) */
cpub?: number[]
/** per-core cpu usage [CPU0..] (0-100 integers) */
cpus?: number[]
// TODO: remove these in future release in favor of la
/** load average 1 minute */
l1?: number
@@ -297,18 +308,18 @@ export interface ChartData {
chartTime: ChartTimes
}
// interface AlertInfo {
// name: () => string
// unit: string
// icon: any
// desc: () => string
// max?: number
// min?: number
// step?: number
// start?: number
// /** Single value description (when there's only one value, like status) */
// singleDesc?: () => string
// }
export interface AlertInfo {
name: () => string
unit: string
icon: any
desc: () => string
max?: number
min?: number
step?: number
start?: number
/** Single value description (when there's only one value, like status) */
singleDesc?: () => string
}
export type AlertMap = Record<string, Map<string, AlertRecord>>
@@ -352,4 +363,131 @@ export interface SmartAttribute {
rs?: string
/** when failed */
wf?: string
}
export interface SystemdRecord extends RecordModel {
system: string
name: string
state: ServiceStatus
sub: ServiceSubState
cpu: number
cpuPeak: number
memory: number
memPeak: number
updated: number
}
export interface SystemdServiceDetails {
AccessSELinuxContext: string;
ActivationDetails: any[];
ActiveEnterTimestamp: number;
ActiveEnterTimestampMonotonic: number;
ActiveExitTimestamp: number;
ActiveExitTimestampMonotonic: number;
ActiveState: string;
After: string[];
AllowIsolate: boolean;
AssertResult: boolean;
AssertTimestamp: number;
AssertTimestampMonotonic: number;
Asserts: any[];
Before: string[];
BindsTo: any[];
BoundBy: any[];
CPUUsageNSec: number;
CanClean: any[];
CanFreeze: boolean;
CanIsolate: boolean;
CanLiveMount: boolean;
CanReload: boolean;
CanStart: boolean;
CanStop: boolean;
CollectMode: string;
ConditionResult: boolean;
ConditionTimestamp: number;
ConditionTimestampMonotonic: number;
Conditions: any[];
ConflictedBy: any[];
Conflicts: string[];
ConsistsOf: any[];
DebugInvocation: boolean;
DefaultDependencies: boolean;
Description: string;
Documentation: string[];
DropInPaths: any[];
ExecMainPID: number;
FailureAction: string;
FailureActionExitStatus: number;
Following: string;
FragmentPath: string;
FreezerState: string;
Id: string;
IgnoreOnIsolate: boolean;
InactiveEnterTimestamp: number;
InactiveEnterTimestampMonotonic: number;
InactiveExitTimestamp: number;
InactiveExitTimestampMonotonic: number;
InvocationID: string;
Job: Array<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[];
}

View File

@@ -1,7 +1,35 @@
## 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
- Add CPU state details and per-core usage. (#1356)
- Add `EXCLUDE_CONTAINERS` environment variable to exclude containers from being monitored. (#1352)
- Add `INTEL_GPU_DEVICE` environment variable to specify Intel GPU device. (#1285)
- Improve parsing of edge case S.M.A.R.T. power on times. (#1347)
- Fix empty disk I/O values for extra disks. (#1355)
- Fix battery nil pointer error. (#1353)
- Add Hebrew with translations by @gabay.
- Update `shoutrrr` and `gopsutil` dependencies.
## 0.15.2
- Improve S.M.A.R.T. device detection logic (fix regression in 0.15.1) (#1345)

View File

@@ -0,0 +1,339 @@
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.