mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-29 00:46:16 +01:00
Compare commits
5 Commits
main
...
l10n_main_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e9c11ab1 | ||
|
|
eea02950e9 | ||
|
|
fd36b0184f | ||
|
|
93868a4965 | ||
|
|
75fd364350 |
@@ -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
|
||||||
|
|||||||
130
agent/docker.go
130
agent/docker.go
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
11
agent/gpu.go
11
agent/gpu.go
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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...`}
|
||||||
|
|||||||
@@ -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 "Настройките за потребителя ти са обновени."
|
||||||
|
|
||||||
|
|||||||
@@ -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 "Ваши настройки пользователя были обновлены."
|
||||||
|
|
||||||
|
|||||||
@@ -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 "已更新您的使用者設定"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user