Compare commits

..

5 Commits

Author SHA1 Message Date
hank
21e9c11ab1 New translations en.po (Bulgarian) 2026-03-28 05:32:42 -04:00
hank
eea02950e9 New translations en.po (Chinese Traditional) 2026-03-27 22:52:48 -04:00
hank
fd36b0184f New translations en.po (Chinese Traditional) 2026-03-27 21:33:15 -04:00
hank
93868a4965 New translations en.po (Chinese Traditional) 2026-03-27 20:33:22 -04:00
hank
75fd364350 New translations en.po (Russian) 2026-03-27 18:12:17 -04:00
14 changed files with 184 additions and 507 deletions

View File

@@ -19,8 +19,6 @@ import (
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
const defaultDataCacheTimeMs uint16 = 60_000
type Agent struct { type Agent struct {
sync.Mutex // Used to lock agent while collecting data sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug debug bool // true if LOG_LEVEL is set to debug
@@ -38,7 +36,6 @@ type Agent struct {
sensorConfig *SensorConfig // Sensors config sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic) systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection) systemDetails system.Details // Host system details (static, once-per-connection)
detailsDirty bool // Whether system details have changed and need to be resent
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events connectionManager *ConnectionManager // Channel to signal connection events
@@ -100,7 +97,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version) slog.Debug(beszel.Version)
// initialize docker manager // initialize docker manager
agent.dockerManager = newDockerManager(agent) agent.dockerManager = newDockerManager()
// initialize system info // initialize system info
agent.refreshSystemDetails() agent.refreshSystemDetails()
@@ -145,7 +142,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true})) slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
} }
return agent, nil return agent, nil
@@ -167,6 +164,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
Info: a.systemInfo, Info: a.systemInfo,
} }
// Include static system details only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs) // slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil { if a.dockerManager != nil {
@@ -179,7 +181,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
} }
// skip updating systemd services if cache time is not the default 60sec interval // skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs { if a.systemdManager != nil && cacheTimeMs == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount()) totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 { if totalCount > 0 {
numFailed := a.systemdManager.getFailedServiceCount() numFailed := a.systemdManager.getFailedServiceCount()
@@ -210,8 +212,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
slog.Debug("Extra FS", "data", data.Stats.ExtraFs) slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
a.cache.Set(data, cacheTimeMs) a.cache.Set(data, cacheTimeMs)
return data
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
} }
// Start initializes and starts the agent with optional WebSocket connection // Start initializes and starts the agent with optional WebSocket connection

View File

@@ -25,7 +25,6 @@ import (
"github.com/henrygd/beszel/agent/deltatracker" "github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils" "github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container" "github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver" "github.com/blang/semver"
) )
@@ -53,7 +52,6 @@ const (
) )
type dockerManager struct { type dockerManager struct {
agent *Agent // Used to propagate system detail changes back to the agent
client *http.Client // Client to query Docker API client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests sem chan struct{} // Semaphore to limit concurrent container requests
@@ -62,7 +60,6 @@ type dockerManager struct {
containerStatsMap map[string]*container.Stats // Keeps track of container stats containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly) goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf decoder *json.Decoder // Reusable JSON decoder that reads from buf
@@ -81,6 +78,7 @@ type dockerManager struct {
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
retrySleep func(time.Duration)
} }
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests // userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -89,14 +87,6 @@ type userAgentRoundTripper struct {
userAgent string userAgent string
} }
// dockerVersionResponse contains the /version fields used for engine checks.
type dockerVersionResponse struct {
Version string `json:"Version"`
Components []struct {
Name string `json:"Name"`
} `json:"Components"`
}
// RoundTrip implements the http.RoundTripper interface // RoundTrip implements the http.RoundTripper interface
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", u.userAgent) req.Header.Set("User-Agent", u.userAgent)
@@ -144,14 +134,7 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
return nil, err return nil, err
} }
// Detect Podman and Windows from Server header dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
serverHeader := resp.Header.Get("Server")
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
dm.setIsPodman()
}
dm.isWindows = strings.Contains(serverHeader, "windows")
dm.ensureDockerVersionChecked()
containersLength := len(dm.apiContainerList) containersLength := len(dm.apiContainerList)
@@ -605,7 +588,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
} }
// Creates a new http client for Docker or Podman API // Creates a new http client for Docker or Podman API
func newDockerManager(agent *Agent) *dockerManager { func newDockerManager() *dockerManager {
dockerHost, exists := utils.GetEnv("DOCKER_HOST") dockerHost, exists := utils.GetEnv("DOCKER_HOST")
if exists { if exists {
// return nil if set to empty string // return nil if set to empty string
@@ -671,7 +654,6 @@ func newDockerManager(agent *Agent) *dockerManager {
} }
manager := &dockerManager{ manager := &dockerManager{
agent: agent,
client: &http.Client{ client: &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: userAgentTransport, Transport: userAgentTransport,
@@ -689,54 +671,51 @@ func newDockerManager(agent *Agent) *dockerManager {
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]), networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time), lastNetworkReadTime: make(map[uint16]map[string]time.Time),
retrySleep: time.Sleep,
} }
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will // If using podman, return client
// retry after the first successful /containers/json request. if strings.Contains(dockerHost, "podman") {
_, _ = manager.checkDockerVersion() manager.usingPodman = true
manager.goodDockerVersion = true
return manager
}
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
go manager.checkDockerVersion()
// give version check a chance to complete before returning
time.Sleep(50 * time.Millisecond)
return manager return manager
} }
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0. // checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch. // Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
func (dm *dockerManager) checkDockerVersion() (bool, error) { func (dm *dockerManager) checkDockerVersion() {
resp, err := dm.client.Get("http://localhost/version") var err error
if err != nil { var resp *http.Response
return false, err var versionInfo struct {
Version string `json:"Version"`
} }
if resp.StatusCode != http.StatusOK { const versionMaxTries = 2
status := resp.Status for i := 1; i <= versionMaxTries; i++ {
resp, err = dm.client.Get("http://localhost/version")
if err == nil && resp.StatusCode == http.StatusOK {
break
}
if resp != nil {
resp.Body.Close() resp.Body.Close()
return false, fmt.Errorf("docker version request failed: %s", status)
} }
if i < versionMaxTries {
var versionInfo dockerVersionResponse slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
serverHeader := resp.Header.Get("Server") dm.retrySleep(5 * time.Second)
if err := dm.decode(resp, &versionInfo); err != nil {
return false, err
} }
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
dm.dockerVersionChecked = true
return true, nil
} }
if err != nil || resp.StatusCode != http.StatusOK {
// ensureDockerVersionChecked retries the version probe after a successful
// container list request.
func (dm *dockerManager) ensureDockerVersionChecked() {
if dm.dockerVersionChecked {
return return
} }
if _, err := dm.checkDockerVersion(); err != nil { if err := dm.decode(resp, &versionInfo); err != nil {
slog.Debug("Failed to get Docker version", "err", err)
}
}
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
if detectPodmanEngine(serverHeader, versionInfo) {
dm.setIsPodman()
return return
} }
// if version > 24, one-shot works correctly and we can limit concurrent operations // if version > 24, one-shot works correctly and we can limit concurrent operations
@@ -962,46 +941,3 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
func (dm *dockerManager) IsPodman() bool { func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman return dm.usingPodman
} }
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
func (dm *dockerManager) setIsPodman() {
if dm.usingPodman {
return
}
dm.usingPodman = true
dm.goodDockerVersion = true
dm.dockerVersionChecked = true
// keep system details updated - this may be detected late if server isn't ready when
// agent starts, so make sure we notify the hub if this happens later.
if dm.agent != nil {
dm.agent.updateSystemDetails(func(details *system.Details) {
details.Podman = true
})
}
}
// detectPodmanFromHeader identifies Podman from the Docker API server header.
func detectPodmanFromHeader(server string) bool {
return strings.HasPrefix(server, "Libpod")
}
// detectPodmanFromVersion identifies Podman from the version payload.
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
if versionInfo == nil {
return false
}
for _, component := range versionInfo.Components {
if strings.HasPrefix(component.Name, "Podman") {
return true
}
}
return false
}
// detectPodmanEngine checks both header and version metadata for Podman.
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
if detectPodmanFromHeader(serverHeader) {
return true
}
return detectPodmanFromVersion(versionInfo)
}

View File

@@ -540,52 +540,58 @@ func TestDockerManagerCreation(t *testing.T) {
func TestCheckDockerVersion(t *testing.T) { func TestCheckDockerVersion(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
responses []struct {
statusCode int statusCode int
body string body string
server string }
expectSuccess bool
expectedGood bool expectedGood bool
expectedPodman bool expectedRequests int
expectError bool
expectedRequest string
}{ }{
{ {
name: "good docker version", name: "200 with good version on first try",
statusCode: http.StatusOK, responses: []struct {
body: `{"Version":"25.0.1"}`, statusCode int
expectSuccess: true, body string
}{
{http.StatusOK, `{"Version":"25.0.1"}`},
},
expectedGood: true, expectedGood: true,
expectedPodman: false, expectedRequests: 1,
expectedRequest: "/version",
}, },
{ {
name: "old docker version", name: "200 with old version on first try",
statusCode: http.StatusOK, responses: []struct {
body: `{"Version":"24.0.7"}`, statusCode int
expectSuccess: true, body string
}{
{http.StatusOK, `{"Version":"24.0.7"}`},
},
expectedGood: false, expectedGood: false,
expectedPodman: false, expectedRequests: 1,
expectedRequest: "/version",
}, },
{ {
name: "podman from server header", name: "non-200 then 200 with good version",
statusCode: http.StatusOK, responses: []struct {
body: `{"Version":"5.5.0"}`, statusCode int
server: "Libpod/5.5.0", body string
expectSuccess: true, }{
{http.StatusServiceUnavailable, `"not ready"`},
{http.StatusOK, `{"Version":"25.1.0"}`},
},
expectedGood: true, expectedGood: true,
expectedPodman: true, expectedRequests: 2,
expectedRequest: "/version",
}, },
{ {
name: "non-200 response", name: "non-200 on all retries",
statusCode: http.StatusServiceUnavailable, responses: []struct {
body: `"not ready"`, statusCode int
expectSuccess: false, body string
}{
{http.StatusInternalServerError, `"error"`},
{http.StatusUnauthorized, `"error"`},
},
expectedGood: false, expectedGood: false,
expectedPodman: false, expectedRequests: 2,
expectError: true,
expectedRequest: "/version",
}, },
} }
@@ -593,13 +599,13 @@ func TestCheckDockerVersion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
requestCount := 0 requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := requestCount
requestCount++ requestCount++
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath()) if idx >= len(tt.responses) {
if tt.server != "" { idx = len(tt.responses) - 1
w.Header().Set("Server", tt.server)
} }
w.WriteHeader(tt.statusCode) w.WriteHeader(tt.responses[idx].statusCode)
fmt.Fprint(w, tt.body) fmt.Fprint(w, tt.responses[idx].body)
})) }))
defer server.Close() defer server.Close()
@@ -611,24 +617,17 @@ func TestCheckDockerVersion(t *testing.T) {
}, },
}, },
}, },
retrySleep: func(time.Duration) {},
} }
success, err := dm.checkDockerVersion() dm.checkDockerVersion()
assert.Equal(t, tt.expectSuccess, success)
assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked)
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion) assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
assert.Equal(t, tt.expectedPodman, dm.usingPodman) assert.Equal(t, tt.expectedRequests, requestCount)
assert.Equal(t, 1, requestCount)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
}) })
} }
t.Run("request error", func(t *testing.T) { t.Run("request error on all retries", func(t *testing.T) {
requestCount := 0 requestCount := 0
dm := &dockerManager{ dm := &dockerManager{
client: &http.Client{ client: &http.Client{
@@ -639,171 +638,16 @@ func TestCheckDockerVersion(t *testing.T) {
}, },
}, },
}, },
retrySleep: func(time.Duration) {},
} }
success, err := dm.checkDockerVersion() dm.checkDockerVersion()
assert.False(t, success)
require.Error(t, err)
assert.False(t, dm.dockerVersionChecked)
assert.False(t, dm.goodDockerVersion) assert.False(t, dm.goodDockerVersion)
assert.False(t, dm.usingPodman) assert.Equal(t, 2, requestCount)
assert.Equal(t, 1, requestCount)
}) })
} }
// newDockerManagerForVersionTest creates a dockerManager wired to a test server.
func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager {
return &dockerManager{
client: &http.Client{
Transport: &http.Transport{
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
return net.Dial(network, server.Listener.Addr().String())
},
},
},
containerStatsMap: make(map[string]*container.Stats),
lastCpuContainer: make(map[uint16]map[string]uint64),
lastCpuSystem: make(map[uint16]map[string]uint64),
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
}
func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
tests := []struct {
name string
containerServer string
versionServer string
versionBody string
expectedGood bool
expectedPodman bool
}{
{
name: "200 with good version on first try",
versionBody: `{"Version":"25.0.1"}`,
expectedGood: true,
expectedPodman: false,
},
{
name: "200 with old version on first try",
versionBody: `{"Version":"24.0.7"}`,
expectedGood: false,
expectedPodman: false,
},
{
name: "podman detected from server header",
containerServer: "Libpod/5.5.0",
expectedGood: true,
expectedPodman: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestCounts := map[string]int{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCounts[r.URL.EscapedPath()]++
switch r.URL.EscapedPath() {
case "/containers/json":
if tt.containerServer != "" {
w.Header().Set("Server", tt.containerServer)
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `[]`)
case "/version":
if tt.versionServer != "" {
w.Header().Set("Server", tt.versionServer)
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, tt.versionBody)
default:
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
}
}))
defer server.Close()
dm := newDockerManagerForVersionTest(server)
stats, err := dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
assert.Empty(t, stats)
assert.True(t, dm.dockerVersionChecked)
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
assert.Equal(t, 1, requestCounts["/containers/json"])
if tt.expectedPodman {
assert.Equal(t, 0, requestCounts["/version"])
} else {
assert.Equal(t, 1, requestCounts["/version"])
}
stats, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
assert.Empty(t, stats)
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
assert.Equal(t, 2, requestCounts["/containers/json"])
if tt.expectedPodman {
assert.Equal(t, 0, requestCounts["/version"])
} else {
assert.Equal(t, 1, requestCounts["/version"])
}
})
}
}
func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
requestCounts := map[string]int{}
versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK}
versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCounts[r.URL.EscapedPath()]++
switch r.URL.EscapedPath() {
case "/containers/json":
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `[]`)
case "/version":
idx := requestCounts["/version"] - 1
if idx >= len(versionStatuses) {
idx = len(versionStatuses) - 1
}
w.WriteHeader(versionStatuses[idx])
fmt.Fprint(w, versionBodies[idx])
default:
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
}
}))
defer server.Close()
dm := newDockerManagerForVersionTest(server)
stats, err := dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
assert.Empty(t, stats)
assert.False(t, dm.dockerVersionChecked)
assert.False(t, dm.goodDockerVersion)
assert.Equal(t, 1, requestCounts["/version"])
stats, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
assert.Empty(t, stats)
assert.True(t, dm.dockerVersionChecked)
assert.True(t, dm.goodDockerVersion)
assert.Equal(t, 2, requestCounts["/containers/json"])
assert.Equal(t, 2, requestCounts["/version"])
stats, err = dm.getDockerStats(defaultCacheTimeMs)
require.NoError(t, err)
assert.Empty(t, stats)
assert.Equal(t, 3, requestCounts["/containers/json"])
assert.Equal(t, 2, requestCounts["/version"])
}
func TestCycleCpuDeltas(t *testing.T) { func TestCycleCpuDeltas(t *testing.T) {
dm := &dockerManager{ dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{ lastCpuContainer: map[uint16]map[string]uint64{

View File

@@ -542,7 +542,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return map[collectorSource]collectorDefinition{ return map[collectorSource]collectorDefinition{
collectorSourceNVML: { collectorSourceNVML: {
group: collectorGroupNvidia, group: collectorGroupNvidia,
available: true, available: caps.hasNvidiaSmi,
start: func(_ func()) bool { start: func(_ func()) bool {
return gm.startNvmlCollector() return gm.startNvmlCollector()
}, },
@@ -734,6 +734,9 @@ func NewGPUManager() (*GPUManager, error) {
} }
var gm GPUManager var gm GPUManager
caps := gm.discoverGpuCapabilities() caps := gm.discoverGpuCapabilities()
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
gm.GpuDataMap = make(map[string]*system.GPUData) gm.GpuDataMap = make(map[string]*system.GPUData)
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR). // Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
@@ -742,7 +745,7 @@ func NewGPUManager() (*GPUManager, error) {
return &gm, nil return &gm, nil
} }
// Respect explicit collector selection before capability auto-detection. // if GPU_COLLECTOR is set, start user-defined collectors.
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" { if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
priorities := parseCollectorPriority(collectorConfig) priorities := parseCollectorPriority(collectorConfig)
if gm.startCollectorsByPriority(priorities, caps) == 0 { if gm.startCollectorsByPriority(priorities, caps) == 0 {
@@ -751,10 +754,6 @@ func NewGPUManager() (*GPUManager, error) {
return &gm, nil return &gm, nil
} }
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
// auto-detect and start collectors when GPU_COLLECTOR is unset. // auto-detect and start collectors when GPU_COLLECTOR is unset.
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 { if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
return nil, fmt.Errorf(noGPUFoundMsg) return nil, fmt.Errorf(noGPUFoundMsg)

View File

@@ -1461,25 +1461,6 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
}) })
} }
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
gm := &GPUManager{}
definitions := gm.collectorDefinitions(gpuCapabilities{})
require.Contains(t, definitions, collectorSourceNVML)
assert.True(t, definitions[collectorSourceNVML].available)
}
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
gm, err := NewGPUManager()
require.Nil(t, gm)
require.Error(t, err)
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
assert.NotContains(t, err.Error(), noGPUFoundMsg)
}
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) { func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("PATH", dir) t.Setenv("PATH", dir)

View File

@@ -193,7 +193,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
// handleLegacyStats serves the legacy one-shot stats payload for older hubs // handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error { func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs}) stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
return a.writeToSession(w, stats, hubVersion) return a.writeToSession(w, stats, hubVersion)
} }

View File

@@ -115,26 +115,6 @@ func (a *Agent) refreshSystemDetails() {
} }
} }
// attachSystemDetails returns details only for fresh default-interval responses.
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
return data
}
// copy data to avoid adding details to the original cached struct
response := *data
response.Details = &a.systemDetails
a.detailsDirty = false
return &response
}
// updateSystemDetails applies a mutation to the static details payload and marks
// it for inclusion on the next fresh default-interval response.
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
updateFunc(&a.systemDetails)
a.detailsDirty = true
}
// Returns current info, stats about the host system // Returns current info, stats about the host system
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats { func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats var systemStats system.Stats

View File

@@ -1,61 +0,0 @@
package agent
import (
"testing"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
agent := &Agent{
cache: NewSystemDataCache(),
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
detailsDirty: true,
}
cached := &system.CombinedData{
Info: system.Info{Hostname: "cached-host"},
}
agent.cache.Set(cached, defaultDataCacheTimeMs)
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
assert.Same(t, cached, response)
assert.Nil(t, response.Details)
assert.True(t, agent.detailsDirty)
assert.Equal(t, "cached-host", response.Info.Hostname)
assert.Nil(t, cached.Details)
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
assert.Same(t, cached, secondResponse)
assert.Nil(t, secondResponse.Details)
}
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
agent := &Agent{}
agent.updateSystemDetails(func(details *system.Details) {
details.Hostname = "updated-host"
details.Podman = true
})
assert.True(t, agent.detailsDirty)
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
assert.True(t, agent.systemDetails.Podman)
original := &system.CombinedData{}
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
assert.Same(t, original, realTimeResponse)
assert.Nil(t, realTimeResponse.Details)
assert.True(t, agent.detailsDirty)
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
require.NotNil(t, response.Details)
assert.NotSame(t, original, response)
assert.Equal(t, "updated-host", response.Details.Hostname)
assert.True(t, response.Details.Podman)
assert.False(t, agent.detailsDirty)
assert.Nil(t, original.Details)
}

View File

@@ -67,8 +67,8 @@ export default function AreaChartDefault({
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats const sourceData = customData ?? chartData.systemStats
// Only update the rendered data while the chart is visible
const [displayData, setDisplayData] = useState(sourceData) const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
// Reduce chart redraws by only updating while visible or when chart time changes // Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => { useEffect(() => {
@@ -78,10 +78,7 @@ export default function AreaChartDefault({
if (shouldUpdate) { if (shouldUpdate) {
setDisplayData(sourceData) setDisplayData(sourceData)
} }
if (isIntersecting && maxToggled !== displayMaxToggled) { }, [displayData, isIntersecting, sourceData])
setDisplayMaxToggled(maxToggled)
}
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
// Use a stable key derived from data point identities and visual properties // Use a stable key derived from data point identities and visual properties
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0") const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
@@ -109,14 +106,14 @@ export default function AreaChartDefault({
/> />
) )
}) })
}, [areasKey, displayMaxToggled]) }, [areasKey, maxToggled])
return useMemo(() => { return useMemo(() => {
if (displayData.length === 0) { if (displayData.length === 0) {
return null return null
} }
// if (logRender) { // if (logRender) {
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date()) // console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
// } // }
return ( return (
<ChartContainer <ChartContainer
@@ -166,5 +163,5 @@ export default function AreaChartDefault({
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
) )
}, [displayData, yAxisWidth, filter, Areas]) }, [displayData, yAxisWidth, showTotal, filter])
} }

View File

@@ -66,8 +66,8 @@ export default function LineChartDefault({
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats const sourceData = customData ?? chartData.systemStats
// Only update the rendered data while the chart is visible
const [displayData, setDisplayData] = useState(sourceData) const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
// Reduce chart redraws by only updating while visible or when chart time changes // Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => { useEffect(() => {
@@ -77,10 +77,7 @@ export default function LineChartDefault({
if (shouldUpdate) { if (shouldUpdate) {
setDisplayData(sourceData) setDisplayData(sourceData)
} }
if (isIntersecting && maxToggled !== displayMaxToggled) { }, [displayData, isIntersecting, sourceData])
setDisplayMaxToggled(maxToggled)
}
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
// Use a stable key derived from data point identities and visual properties // Use a stable key derived from data point identities and visual properties
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0") const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
@@ -108,14 +105,14 @@ export default function LineChartDefault({
/> />
) )
}) })
}, [linesKey, displayMaxToggled]) }, [linesKey, maxToggled])
return useMemo(() => { return useMemo(() => {
if (displayData.length === 0) { if (displayData.length === 0) {
return null return null
} }
// if (logRender) { // if (logRender) {
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date()) // console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
// } // }
return ( return (
<ChartContainer <ChartContainer
@@ -165,5 +162,5 @@ export default function LineChartDefault({
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
) )
}, [displayData, yAxisWidth, filter, Lines]) }, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime])
} }

View File

@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react" import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns" import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Card, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -161,13 +161,13 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
<CardTitle className="mb-2"> <CardTitle className="mb-2">
<Trans>Systemd Services</Trans> <Trans>Systemd Services</Trans>
</CardTitle> </CardTitle>
<div className="text-sm text-muted-foreground flex items-center flex-wrap"> <CardDescription className="flex items-center">
<Trans>Total: {data.length}</Trans> <Trans>Total: {data.length}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" /> <Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans> <Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" /> <Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Updated every 10 minutes.</Trans> <Trans>Updated every 10 minutes.</Trans>
</div> </CardDescription>
</div> </div>
<Input <Input
placeholder={t`Filter...`} placeholder={t`Filter...`}

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n" "Language: bg\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-03-27 19:17\n" "PO-Revision-Date: 2026-03-28 09:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Bulgarian\n" "Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -22,7 +22,7 @@ msgstr ""
#: src/components/footer-repo-link.tsx #: src/components/footer-repo-link.tsx
msgctxt "New version available" msgctxt "New version available"
msgid "{0} available" msgid "{0} available"
msgstr "" msgstr "Версия {0} е налична"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
@@ -476,7 +476,7 @@ msgstr "Копирай YAML"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgctxt "Core system metrics" msgctxt "Core system metrics"
msgid "Core" msgid "Core"
msgstr "" msgstr "Основни"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
@@ -550,7 +550,7 @@ msgstr "Дневно"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option" msgctxt "Default system layout option"
msgid "Default" msgid "Default"
msgstr "" msgstr "Подредба"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Default time period" msgid "Default time period"
@@ -611,7 +611,7 @@ msgstr "Изполване на диск от {extraFsName}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options" msgctxt "Layout display options"
msgid "Display" msgid "Display"
msgstr "" msgstr "Показване"
#: src/components/routes/system/charts/cpu-charts.tsx #: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage" msgid "Docker CPU Usage"
@@ -845,7 +845,7 @@ msgstr "Глобален"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -870,7 +870,7 @@ msgstr "Здраве"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -1259,7 +1259,7 @@ msgstr "Порт"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
msgid "Ports" msgid "Ports"
msgstr "" msgstr "Портове"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1549,7 +1549,7 @@ msgstr "Таблица"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option" msgctxt "Tabs system layout option"
msgid "Tabs" msgid "Tabs"
msgstr "" msgstr "Табове"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -1879,3 +1879,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Настройките за потребителя ти са обновени." msgstr "Настройките за потребителя ти са обновени."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ru\n" "Language: ru\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-02-21 09:46\n" "PO-Revision-Date: 2026-03-27 22:12\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Russian\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" "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"
@@ -22,7 +22,7 @@ msgstr ""
#: src/components/footer-repo-link.tsx #: src/components/footer-repo-link.tsx
msgctxt "New version available" msgctxt "New version available"
msgid "{0} available" msgid "{0} available"
msgstr "" msgstr "{0} доступно"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
@@ -476,7 +476,7 @@ msgstr "Скопировать YAML"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgctxt "Core system metrics" msgctxt "Core system metrics"
msgid "Core" msgid "Core"
msgstr "" msgstr "Процессор"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
@@ -550,7 +550,7 @@ msgstr "Ежедневно"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option" msgctxt "Default system layout option"
msgid "Default" msgid "Default"
msgstr "" msgstr "По умолчанию"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Default time period" msgid "Default time period"
@@ -611,7 +611,7 @@ msgstr "Использование диска {extraFsName}"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options" msgctxt "Layout display options"
msgid "Display" msgid "Display"
msgstr "" msgstr "Отображение"
#: src/components/routes/system/charts/cpu-charts.tsx #: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage" msgid "Docker CPU Usage"
@@ -845,7 +845,7 @@ msgstr "Глобально"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -870,7 +870,7 @@ msgstr "Здоровье"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -1259,7 +1259,7 @@ msgstr "Порт"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
msgid "Ports" msgid "Ports"
msgstr "" msgstr "Порты"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1549,7 +1549,7 @@ msgstr "Таблица"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option" msgctxt "Tabs system layout option"
msgid "Tabs" msgid "Tabs"
msgstr "" msgstr "Вкладки"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -1879,3 +1879,4 @@ msgstr "Да"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "Ваши настройки пользователя были обновлены." msgstr "Ваши настройки пользователя были обновлены."

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n" "Language: zh\n"
"Project-Id-Version: beszel\n" "Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-03-27 19:17\n" "PO-Revision-Date: 2026-03-28 02:52\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Chinese Traditional\n" "Language-Team: Chinese Traditional\n"
"Plural-Forms: nplurals=1; plural=0;\n" "Plural-Forms: nplurals=1; plural=0;\n"
@@ -22,7 +22,7 @@ msgstr ""
#: src/components/footer-repo-link.tsx #: src/components/footer-repo-link.tsx
msgctxt "New version available" msgctxt "New version available"
msgid "{0} available" msgid "{0} available"
msgstr "" msgstr "{0} 現已推出"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length #. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length #. placeholder {1}: table.getFilteredRowModel().rows.length
@@ -145,11 +145,11 @@ msgstr "之後"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "After setting the environment variables, restart your Beszel hub for changes to take effect." msgid "After setting the environment variables, restart your Beszel hub for changes to take effect."
msgstr "設環境變數後,重新啟動您的 Beszel hub 以使更生效。" msgstr "設環境變數後,重新啟動 Beszel Hub 以使更生效。"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "Agent" msgid "Agent"
msgstr "" msgstr "Agent"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx #: src/components/routes/settings/alerts-history-data-table.tsx
@@ -476,13 +476,13 @@ msgstr "複製 YAML"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgctxt "Core system metrics" msgctxt "Core system metrics"
msgid "Core" msgid "Core"
msgstr "" msgstr "核心指標"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx #: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgid "CPU" msgid "CPU"
msgstr "" msgstr "CPU"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores" msgid "CPU Cores"
@@ -550,7 +550,7 @@ msgstr "每日"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option" msgctxt "Default system layout option"
msgid "Default" msgid "Default"
msgstr "" msgstr "預設"
#: src/components/routes/settings/general.tsx #: src/components/routes/settings/general.tsx
msgid "Default time period" msgid "Default time period"
@@ -611,7 +611,7 @@ msgstr "{extraFsName}的磁碟使用量"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options" msgctxt "Layout display options"
msgid "Display" msgid "Display"
msgstr "" msgstr "顯示"
#: src/components/routes/system/charts/cpu-charts.tsx #: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage" msgid "Docker CPU Usage"
@@ -682,11 +682,11 @@ msgstr "結束時間"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL" msgid "Endpoint URL"
msgstr "端點 URL" msgstr "Endpoint URL"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Endpoint URL to ping (required)" msgid "Endpoint URL to ping (required)"
msgstr "要 ping 的端點 URL (必填)" msgstr "要 Ping 的 Endpoint URL (必填)"
#: src/components/login/login.tsx #: src/components/login/login.tsx
msgid "Enter email address to reset password" msgid "Enter email address to reset password"
@@ -781,7 +781,7 @@ msgstr "儲存設定失敗"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Failed to send heartbeat" msgid "Failed to send heartbeat"
msgstr "發送 heartbeat 失敗" msgstr "Heartbeat 發送失敗"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Failed to send test notification" msgid "Failed to send test notification"
@@ -807,7 +807,7 @@ msgstr "篩選..."
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint" msgid "Fingerprint"
msgstr "" msgstr "Fingerprint"
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
msgid "Firmware" msgid "Firmware"
@@ -845,7 +845,7 @@ msgstr "全域"
#: src/components/routes/system.tsx #: src/components/routes/system.tsx
msgid "GPU" msgid "GPU"
msgstr "" msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx #: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines" msgid "GPU Engines"
@@ -870,7 +870,7 @@ msgstr "健康狀態"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Heartbeat" msgid "Heartbeat"
msgstr "心跳 (Heartbeat)" msgstr "Heartbeat"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring" msgid "Heartbeat Monitoring"
@@ -888,7 +888,7 @@ msgstr "Homebrew 指令"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Host / IP" msgid "Host / IP"
msgstr "" msgstr "Host / IP"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method" msgid "HTTP Method"
@@ -1200,7 +1200,7 @@ msgstr "已暫停 ({pausedSystemsLength})"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Payload format" msgid "Payload format"
msgstr "有效載荷 (Payload) 格式" msgstr "Payload 格式"
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/cpu-sheet.tsx #: src/components/routes/system/cpu-sheet.tsx
@@ -1254,12 +1254,12 @@ msgstr "請登入您的帳號"
#: src/components/add-system.tsx #: src/components/add-system.tsx
msgid "Port" msgid "Port"
msgstr "" msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx #: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports" msgctxt "Container ports"
msgid "Ports" msgid "Ports"
msgstr "" msgstr "Port 映射"
#. Power On Time #. Power On Time
#: src/components/routes/system/smart-table.tsx #: src/components/routes/system/smart-table.tsx
@@ -1345,7 +1345,7 @@ msgstr "繼續"
#: src/components/systems-table/systems-table-columns.tsx #: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label" msgctxt "Root disk label"
msgid "Root" msgid "Root"
msgstr "" msgstr "Root"
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token" msgid "Rotate token"
@@ -1418,15 +1418,15 @@ msgstr "選取{foo}"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working." msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "發送單個 heartbeat ping 以驗證您的端點是否正常工作。" msgstr "發送單次 Heartbeat Ping 以驗證您的 Endpoint 是否運作正常。"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet." msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
msgstr "定期向外部監控服務發送出站 ping以便您在不將 Beszel 暴露於網際網路的情況下進行監控。" msgstr "定期發送 Outbound Ping 至外部監控服務,讓您無需將 Beszel 暴露於網際網路即可進行監控。"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Send test heartbeat" msgid "Send test heartbeat"
msgstr "發送測試 heartbeat" msgstr "發送測試 Heartbeat"
#: src/components/routes/system/charts/network-charts.tsx #: src/components/routes/system/charts/network-charts.tsx
msgid "Sent" msgid "Sent"
@@ -1451,7 +1451,7 @@ msgstr "設定儀表顏色的百分比閾值。"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:" msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "在您的 Beszel hub 上設置以下環境變數以啟用 heartbeat 監控:" msgstr "設定以下環境變數至 Beszel Hub 以啟用 Heartbeat 監控:"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
@@ -1549,7 +1549,7 @@ msgstr "列表"
#: src/components/routes/system/info-bar.tsx #: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option" msgctxt "Tabs system layout option"
msgid "Tabs" msgid "Tabs"
msgstr "" msgstr "分頁"
#: src/components/systemd-table/systemd-table.tsx #: src/components/systemd-table/systemd-table.tsx
msgid "Tasks" msgid "Tasks"
@@ -1580,7 +1580,7 @@ msgstr "測試<0>URL</0>"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat" msgid "Test heartbeat"
msgstr "測試 heartbeat" msgstr "測試 Heartbeat"
#: src/components/routes/settings/notifications.tsx #: src/components/routes/settings/notifications.tsx
msgid "Test notification sent" msgid "Test notification sent"
@@ -1588,7 +1588,7 @@ msgstr "已發送測試通知"
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down." msgid "The overall status is <0>ok</0> when all systems are up, <1>warn</1> when alerts are triggered, and <2>error</2> when any system is down."
msgstr "當所有系統都正常運行時,整體狀態為 <0>ok</0>;當觸發警報時為 <1>警告</1>;當任何系統故障時為 <2>錯誤</2>。" msgstr "當所有系統都正常運行時,整體狀態為 <0>正常</0>;當觸發警報時為 <1>警告</1>;當任何系統故障時為 <2>錯誤</2>。"
#: src/components/login/forgot-pass-form.tsx #: src/components/login/forgot-pass-form.tsx
msgid "Then log into the backend and reset your user account password in the users table." msgid "Then log into the backend and reset your user account password in the users table."
@@ -1626,7 +1626,7 @@ msgstr "切換主題"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token" msgid "Token"
msgstr "" msgstr "Token"
#: src/components/command-palette.tsx #: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
@@ -1848,7 +1848,7 @@ msgstr "啟用後,此 Token 可讓 Agent 自行註冊,無需預先在系統
#: src/components/routes/settings/heartbeat.tsx #: src/components/routes/settings/heartbeat.tsx
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts." msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
msgstr "使用 POST 時,每個 heartbeat 都包含一個 JSON 有效載荷,其中包含系統狀態摘要、故障系統列表和觸發的警報。" msgstr "使用 POST 時,每個 Heartbeat 都包含一個 JSON Payload內容涵蓋系統狀態概況、離線系統清單以及已觸發的警報。"
#: src/components/add-system.tsx #: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx #: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1879,3 +1879,4 @@ msgstr "是"
#: src/components/routes/settings/layout.tsx #: src/components/routes/settings/layout.tsx
msgid "Your user settings have been updated." msgid "Your user settings have been updated."
msgstr "已更新您的使用者設定" msgstr "已更新您的使用者設定"