mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-14 00:41:50 +02:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf123a99e | ||
|
|
97394e775f | ||
|
|
d5c381188b | ||
|
|
b107d12a62 | ||
|
|
e646f2c1fc | ||
|
|
b18528d24a | ||
|
|
a6e64df399 | ||
|
|
66ba21dd41 | ||
|
|
1851e7a111 | ||
|
|
74b78e96b3 | ||
|
|
a9657f9c00 | ||
|
|
1dee63a0eb | ||
|
|
d608cf0955 | ||
|
|
b9139a1f9b | ||
|
|
7f372c46db | ||
|
|
40010ad9b9 | ||
|
|
5927f45a4a | ||
|
|
962613df7c | ||
|
|
92b1f236e3 | ||
|
|
a911670a2d | ||
|
|
b0cb0c2269 | ||
|
|
735d03577f | ||
|
|
a33f88d822 | ||
|
|
dfd1fc8fda | ||
|
|
1df08801a2 | ||
|
|
62f5f986bb | ||
|
|
a87b9af9d5 | ||
|
|
03900e54cc | ||
|
|
f4abbd1a5b | ||
|
|
77ed90cb4a | ||
|
|
2fe3b1adb1 | ||
|
|
f56093d0f0 | ||
|
|
77dba42f17 | ||
|
|
e233a0b0dc | ||
|
|
18e4c88875 | ||
|
|
904a6038cd | ||
|
|
ae55b86493 | ||
|
|
5360f762e4 | ||
|
|
0d464787f2 | ||
|
|
24f72ef596 | ||
|
|
2d8739052b | ||
|
|
1e32d13650 | ||
|
|
dbf3f94247 | ||
|
|
8a81c7bbac | ||
|
|
d24150c78b | ||
|
|
013da18789 | ||
|
|
5b663621e4 | ||
|
|
4056345216 | ||
|
|
d00c0488c3 | ||
|
|
d352ce00fa | ||
|
|
1623f5e751 | ||
|
|
612ad1238f | ||
|
|
1ad4409609 | ||
|
|
2a94e1d1ec | ||
|
|
75b372437c | ||
|
|
b661d00159 | ||
|
|
898dbf73c8 | ||
|
|
e099304948 | ||
|
|
b61b7a12dc | ||
|
|
37769050e5 | ||
|
|
d81e137291 | ||
|
|
ae820d348e | ||
|
|
ddb298ac7c | ||
|
|
cca7b36039 | ||
|
|
adda381d9d | ||
|
|
1630b1558f | ||
|
|
733c10ff31 | ||
|
|
ed3fd185d3 | ||
|
|
b1fd7e6695 | ||
|
|
7d6230de74 | ||
|
|
f9a39c6004 | ||
|
|
f21a6d15fe | ||
|
|
bf38716095 | ||
|
|
45816e7de6 | ||
|
|
2a6946906e | ||
|
|
ca58ff66ba | ||
|
|
133d229361 | ||
|
|
960cac4060 | ||
|
|
d83865cb4f | ||
|
|
4b43d68da6 | ||
|
|
c790d76211 |
97
.github/workflows/docker-images.yml
vendored
97
.github/workflows/docker-images.yml
vendored
@@ -12,65 +12,137 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
|
# henrygd/beszel
|
||||||
- image: henrygd/beszel
|
- image: henrygd/beszel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# henrygd/beszel-agent
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# henrygd/beszel-agent-nvidia
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# henrygd/beszel-agent-intel
|
||||||
- image: henrygd/beszel-agent-intel
|
- image: henrygd/beszel-agent-intel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# henrygd/beszel-agent:alpine
|
||||||
|
- image: henrygd/beszel-agent
|
||||||
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=alpine
|
||||||
|
type=semver,pattern={{version}}-alpine
|
||||||
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
|
# ghcr.io/henrygd/beszel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# ghcr.io/henrygd/beszel-agent
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# ghcr.io/henrygd/beszel-agent-nvidia
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia
|
dockerfile: ./internal/dockerfile_agent_nvidia
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# ghcr.io/henrygd/beszel-agent-intel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-intel
|
||||||
context: ./
|
|
||||||
dockerfile: ./internal/dockerfile_agent_intel
|
dockerfile: ./internal/dockerfile_agent_intel
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# ghcr.io/henrygd/beszel-agent:alpine
|
||||||
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password_secret: GITHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=alpine
|
||||||
|
type=semver,pattern={{version}}-alpine
|
||||||
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -100,12 +172,7 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: |
|
tags: ${{ matrix.tags }}
|
||||||
type=raw,value=edge
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
@@ -123,7 +190,7 @@ jobs:
|
|||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: "${{ matrix.context }}"
|
context: ./
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
platforms: ${{ matrix.platforms || 'linux/amd64,linux/arm64,linux/arm/v7' }}
|
||||||
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
push: ${{ github.ref_type == 'tag' && secrets[matrix.password_secret] != '' }}
|
||||||
|
|||||||
@@ -12,33 +12,37 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||||
sensorConfig *SensorConfig // Sensors config
|
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||||
systemInfo system.Info // Host system info
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
gpuManager *GPUManager // Manages GPU data
|
sensorConfig *SensorConfig // Sensors config
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
systemInfo system.Info // Host system info
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
gpuManager *GPUManager // Manages GPU data
|
||||||
server *ssh.Server // SSH server
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
dataDir string // Directory for persisting data
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
keys []gossh.PublicKey // SSH public keys
|
handlerRegistry *HandlerRegistry // Registry for routing incoming messages
|
||||||
|
server *ssh.Server // SSH server
|
||||||
|
dataDir string // Directory for persisting data
|
||||||
|
keys []gossh.PublicKey // SSH public keys
|
||||||
|
smartManager *SmartManager // Manages SMART data
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -46,9 +50,15 @@ type Agent struct {
|
|||||||
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||||
agent = &Agent{
|
agent = &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
cache: NewSessionCache(69 * time.Second),
|
cache: NewSystemDataCache(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize disk I/O previous counters storage
|
||||||
|
agent.diskPrev = make(map[uint16]map[string]prevDisk)
|
||||||
|
// Initialize per-cache-time network tracking structures
|
||||||
|
agent.netIoStats = make(map[uint16]system.NetIoStats)
|
||||||
|
agent.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||||
|
|
||||||
agent.dataDir, err = getDataDir(dataDir...)
|
agent.dataDir, err = getDataDir(dataDir...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Data directory not found")
|
slog.Warn("Data directory not found")
|
||||||
@@ -79,6 +89,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize connection manager
|
// initialize connection manager
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
|
|
||||||
|
// initialize handler registry
|
||||||
|
agent.handlerRegistry = NewHandlerRegistry()
|
||||||
|
|
||||||
// initialize disk info
|
// initialize disk info
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
|
|
||||||
@@ -88,16 +101,20 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
|
agent.smartManager, err = NewSmartManager()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("SMART", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
if gm, err := NewGPUManager(); err != nil {
|
agent.gpuManager, err = NewGPUManager()
|
||||||
|
if err != nil {
|
||||||
slog.Debug("GPU", "err", err)
|
slog.Debug("GPU", "err", err)
|
||||||
} else {
|
|
||||||
agent.gpuManager = gm
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(""))
|
slog.Debug("Stats", "data", agent.gatherStats(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -112,24 +129,24 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
data, isCached := a.cache.Get(sessionID)
|
data, isCached := a.cache.Get(cacheTimeMs)
|
||||||
if isCached {
|
if isCached {
|
||||||
slog.Debug("Cached data", "session", sessionID)
|
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
*data = system.CombinedData{
|
*data = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(cacheTimeMs),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System data", "data", data)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(cacheTimeMs); err == nil {
|
||||||
data.Containers = containerStats
|
data.Containers = containerStats
|
||||||
slog.Debug("Containers", "data", data.Containers)
|
slog.Debug("Containers", "data", data.Containers)
|
||||||
} else {
|
} else {
|
||||||
@@ -140,12 +157,17 @@ func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
|||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
data.Stats.ExtraFs[name] = stats
|
// Use custom name if available, otherwise use device name
|
||||||
|
key := name
|
||||||
|
if stats.Name != "" {
|
||||||
|
key = stats.Name
|
||||||
|
}
|
||||||
|
data.Stats.ExtraFs[key] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(sessionID, data)
|
a.cache.Set(data, cacheTimeMs)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,55 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Not thread safe since we only access from gatherStats which is already locked
|
type systemDataCache struct {
|
||||||
type SessionCache struct {
|
sync.RWMutex
|
||||||
data *system.CombinedData
|
cache map[uint16]*cacheNode
|
||||||
lastUpdate time.Time
|
|
||||||
primarySession string
|
|
||||||
leaseTime time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSessionCache(leaseTime time.Duration) *SessionCache {
|
type cacheNode struct {
|
||||||
return &SessionCache{
|
data *system.CombinedData
|
||||||
leaseTime: leaseTime,
|
lastUpdate time.Time
|
||||||
data: &system.CombinedData{},
|
}
|
||||||
|
|
||||||
|
// NewSystemDataCache creates a cache keyed by the polling interval in milliseconds.
|
||||||
|
func NewSystemDataCache() *systemDataCache {
|
||||||
|
return &systemDataCache{
|
||||||
|
cache: make(map[uint16]*cacheNode),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
|
// Get returns cached combined data when the entry is still considered fresh.
|
||||||
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
|
func (c *systemDataCache) Get(cacheTimeMs uint16) (stats *system.CombinedData, isCached bool) {
|
||||||
return c.data, true
|
c.RLock()
|
||||||
|
defer c.RUnlock()
|
||||||
|
|
||||||
|
node, ok := c.cache[cacheTimeMs]
|
||||||
|
if !ok {
|
||||||
|
return &system.CombinedData{}, false
|
||||||
}
|
}
|
||||||
return c.data, false
|
// allowedSkew := time.Second
|
||||||
|
// isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs)*time.Millisecond-allowedSkew
|
||||||
|
// allow a 50% skew of the cache time
|
||||||
|
isFresh := time.Since(node.lastUpdate) < time.Duration(cacheTimeMs/2)*time.Millisecond
|
||||||
|
return node.data, isFresh
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
|
// Set stores the latest combined data snapshot for the given interval.
|
||||||
if data != nil {
|
func (c *systemDataCache) Set(data *system.CombinedData, cacheTimeMs uint16) {
|
||||||
*c.data = *data
|
c.Lock()
|
||||||
|
defer c.Unlock()
|
||||||
|
|
||||||
|
node, ok := c.cache[cacheTimeMs]
|
||||||
|
if !ok {
|
||||||
|
node = &cacheNode{}
|
||||||
|
c.cache[cacheTimeMs] = node
|
||||||
}
|
}
|
||||||
c.primarySession = sessionID
|
node.data = data
|
||||||
c.lastUpdate = time.Now()
|
node.lastUpdate = time.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,82 +8,239 @@ import (
|
|||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSessionCache_GetSet(t *testing.T) {
|
func createTestCacheData() *system.CombinedData {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
return &system.CombinedData{
|
||||||
cache := NewSessionCache(69 * time.Second)
|
Stats: system.Stats{
|
||||||
|
Cpu: 50.5,
|
||||||
|
Mem: 8192,
|
||||||
|
DiskTotal: 100000,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
},
|
||||||
|
Containers: []*container.Stats{
|
||||||
|
{
|
||||||
|
Name: "test-container",
|
||||||
|
Cpu: 25.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
testData := &system.CombinedData{
|
func TestNewSystemDataCache(t *testing.T) {
|
||||||
Info: system.Info{
|
cache := NewSystemDataCache()
|
||||||
Hostname: "test-host",
|
require.NotNil(t, cache)
|
||||||
Cores: 4,
|
assert.NotNil(t, cache.cache)
|
||||||
},
|
assert.Empty(t, cache.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheGetSet(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
data := createTestCacheData()
|
||||||
|
|
||||||
|
// Test setting data
|
||||||
|
cache.Set(data, 1000) // 1 second cache
|
||||||
|
|
||||||
|
// Test getting fresh data
|
||||||
|
retrieved, isCached := cache.Get(1000)
|
||||||
|
assert.True(t, isCached)
|
||||||
|
assert.Equal(t, data, retrieved)
|
||||||
|
|
||||||
|
// Test getting non-existent cache key
|
||||||
|
_, isCached = cache.Get(2000)
|
||||||
|
assert.False(t, isCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheFreshness(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
data := createTestCacheData()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
cacheTimeMs uint16
|
||||||
|
sleepMs time.Duration
|
||||||
|
expectFresh bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fresh data - well within cache time",
|
||||||
|
cacheTimeMs: 1000, // 1 second
|
||||||
|
sleepMs: 100, // 100ms
|
||||||
|
expectFresh: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fresh data - at 50% of cache time boundary",
|
||||||
|
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
||||||
|
sleepMs: 499, // just under 500ms
|
||||||
|
expectFresh: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stale data - exactly at 50% cache time",
|
||||||
|
cacheTimeMs: 1000, // 1 second, 50% = 500ms
|
||||||
|
sleepMs: 500, // exactly 500ms
|
||||||
|
expectFresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stale data - well beyond cache time",
|
||||||
|
cacheTimeMs: 1000, // 1 second
|
||||||
|
sleepMs: 800, // 800ms
|
||||||
|
expectFresh: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "short cache time",
|
||||||
|
cacheTimeMs: 200, // 200ms, 50% = 100ms
|
||||||
|
sleepMs: 150, // 150ms > 100ms
|
||||||
|
expectFresh: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
// Set data
|
||||||
|
cache.Set(data, tc.cacheTimeMs)
|
||||||
|
|
||||||
|
// Wait for the specified duration
|
||||||
|
if tc.sleepMs > 0 {
|
||||||
|
time.Sleep(tc.sleepMs * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check freshness
|
||||||
|
_, isCached := cache.Get(tc.cacheTimeMs)
|
||||||
|
assert.Equal(t, tc.expectFresh, isCached)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheMultipleIntervals(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
data1 := createTestCacheData()
|
||||||
|
data2 := &system.CombinedData{
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 50.0,
|
Cpu: 75.0,
|
||||||
MemPct: 30.0,
|
Mem: 16384,
|
||||||
DiskPct: 40.0,
|
|
||||||
},
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "test-host-2",
|
||||||
|
},
|
||||||
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test initial state - should not be cached
|
// Set data for different intervals
|
||||||
data, isCached := cache.Get("session1")
|
cache.Set(data1, 500) // 500ms cache
|
||||||
assert.False(t, isCached, "Expected no cached data initially")
|
cache.Set(data2, 1000) // 1000ms cache
|
||||||
assert.NotNil(t, data, "Expected data to be initialized")
|
|
||||||
// Set data for session1
|
|
||||||
cache.Set("session1", testData)
|
|
||||||
|
|
||||||
time.Sleep(15 * time.Second)
|
// Both should be fresh immediately
|
||||||
|
retrieved1, isCached1 := cache.Get(500)
|
||||||
|
assert.True(t, isCached1)
|
||||||
|
assert.Equal(t, data1, retrieved1)
|
||||||
|
|
||||||
// Get data for a different session - should be cached
|
retrieved2, isCached2 := cache.Get(1000)
|
||||||
data, isCached = cache.Get("session2")
|
assert.True(t, isCached2)
|
||||||
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
assert.Equal(t, data2, retrieved2)
|
||||||
require.NotNil(t, data, "Expected cached data to be returned")
|
|
||||||
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
|
||||||
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
|
|
||||||
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
|
|
||||||
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
|
|
||||||
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
|
|
||||||
|
|
||||||
time.Sleep(10 * time.Second)
|
// Wait 300ms - 500ms cache should be stale (250ms threshold), 1000ms should still be fresh (500ms threshold)
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
|
||||||
// Get data for the primary session - should not be cached
|
_, isCached1 = cache.Get(500)
|
||||||
data, isCached = cache.Get("session1")
|
assert.False(t, isCached1)
|
||||||
assert.False(t, isCached, "Expected data not to be cached for primary session")
|
|
||||||
require.NotNil(t, data, "Expected data to be returned even if not cached")
|
|
||||||
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
|
||||||
// if not cached, agent will update the data
|
|
||||||
cache.Set("session1", testData)
|
|
||||||
|
|
||||||
time.Sleep(45 * time.Second)
|
_, isCached2 = cache.Get(1000)
|
||||||
|
assert.True(t, isCached2)
|
||||||
|
|
||||||
// Get data for a different session - should still be cached
|
// Wait another 300ms (total 600ms) - now 1000ms cache should also be stale
|
||||||
_, isCached = cache.Get("session2")
|
time.Sleep(300 * time.Millisecond)
|
||||||
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
_, isCached2 = cache.Get(1000)
|
||||||
|
assert.False(t, isCached2)
|
||||||
// Wait for the lease to expire
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
|
|
||||||
// Get data for session2 - should not be cached
|
|
||||||
_, isCached = cache.Get("session2")
|
|
||||||
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionCache_NilData(t *testing.T) {
|
func TestCacheOverwrite(t *testing.T) {
|
||||||
// Create a new SessionCache
|
cache := NewSystemDataCache()
|
||||||
cache := NewSessionCache(30 * time.Second)
|
data1 := createTestCacheData()
|
||||||
|
data2 := &system.CombinedData{
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 90.0,
|
||||||
|
Mem: 32768,
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "updated-host",
|
||||||
|
},
|
||||||
|
Containers: []*container.Stats{},
|
||||||
|
}
|
||||||
|
|
||||||
// Test setting nil data (should not panic)
|
// Set initial data
|
||||||
assert.NotPanics(t, func() {
|
cache.Set(data1, 1000)
|
||||||
cache.Set("session1", nil)
|
retrieved, isCached := cache.Get(1000)
|
||||||
}, "Setting nil data should not panic")
|
assert.True(t, isCached)
|
||||||
|
assert.Equal(t, data1, retrieved)
|
||||||
|
|
||||||
// Get data - should not be nil even though we set nil
|
// Overwrite with new data
|
||||||
data, _ := cache.Get("session2")
|
cache.Set(data2, 1000)
|
||||||
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
|
retrieved, isCached = cache.Get(1000)
|
||||||
|
assert.True(t, isCached)
|
||||||
|
assert.Equal(t, data2, retrieved)
|
||||||
|
assert.NotEqual(t, data1, retrieved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheMiss(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
|
||||||
|
// Test getting from empty cache
|
||||||
|
_, isCached := cache.Get(1000)
|
||||||
|
assert.False(t, isCached)
|
||||||
|
|
||||||
|
// Set data for one interval
|
||||||
|
data := createTestCacheData()
|
||||||
|
cache.Set(data, 1000)
|
||||||
|
|
||||||
|
// Test getting different interval
|
||||||
|
_, isCached = cache.Get(2000)
|
||||||
|
assert.False(t, isCached)
|
||||||
|
|
||||||
|
// Test getting after data has expired
|
||||||
|
time.Sleep(600 * time.Millisecond) // 600ms > 500ms (50% of 1000ms)
|
||||||
|
_, isCached = cache.Get(1000)
|
||||||
|
assert.False(t, isCached)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheZeroInterval(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
data := createTestCacheData()
|
||||||
|
|
||||||
|
// Set with zero interval - should allow immediate cache
|
||||||
|
cache.Set(data, 0)
|
||||||
|
|
||||||
|
// With 0 interval, 50% is 0, so it should never be considered fresh
|
||||||
|
// (time.Since(lastUpdate) >= 0, which is not < 0)
|
||||||
|
_, isCached := cache.Get(0)
|
||||||
|
assert.False(t, isCached)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheLargeInterval(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
cache := NewSystemDataCache()
|
||||||
|
data := createTestCacheData()
|
||||||
|
|
||||||
|
// Test with maximum uint16 value
|
||||||
|
cache.Set(data, 65535) // ~65 seconds
|
||||||
|
|
||||||
|
// Should be fresh immediately
|
||||||
|
_, isCached := cache.Get(65535)
|
||||||
|
assert.True(t, isCached)
|
||||||
|
|
||||||
|
// Should still be fresh after a short time
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
_, isCached = cache.Get(65535)
|
||||||
|
assert.True(t, isCached)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ func HasReadableBattery() bool {
|
|||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
bat, err := battery.Get(0)
|
batteries, err := battery.GetAll()
|
||||||
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
for _, bat := range batteries {
|
||||||
|
if bat.Full > 0 {
|
||||||
|
systemHasBattery = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if !systemHasBattery {
|
if !systemHasBattery {
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
@@ -28,24 +33,44 @@ func HasReadableBattery() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBatteryStats returns the current battery percent and charge state
|
// GetBatteryStats returns the current battery percent and charge state
|
||||||
|
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
||||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||||
if !systemHasBattery {
|
if !HasReadableBattery() {
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
batteries, err := battery.GetAll()
|
batteries, err := battery.GetAll()
|
||||||
if err != nil || len(batteries) == 0 {
|
// we'll handle errors later by skipping batteries with errors, rather
|
||||||
return batteryPercent, batteryState, err
|
// than skipping everything because of the presence of some errors.
|
||||||
|
if len(batteries) == 0 {
|
||||||
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCapacity := float64(0)
|
totalCapacity := float64(0)
|
||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
for _, bat := range batteries {
|
errs, partialErrs := err.(battery.Errors)
|
||||||
if bat.Design != 0 {
|
|
||||||
totalCapacity += bat.Design
|
for i, bat := range batteries {
|
||||||
} else {
|
if partialErrs && errs[i] != nil {
|
||||||
totalCapacity += bat.Full
|
// if there were some errors, like missing data, skip it
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if bat.Full == 0 {
|
||||||
|
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
||||||
|
// we can't guarantee that, so don't skip based on charge.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalCapacity += bat.Full
|
||||||
totalCharge += bat.Current
|
totalCharge += bat.Current
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if totalCapacity == 0 {
|
||||||
|
// for macs there's sometimes a ghost battery with 0 capacity
|
||||||
|
// https://github.com/distatus/battery/issues/34
|
||||||
|
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||||
|
// and return an error. This also prevents a divide by zero.
|
||||||
|
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||||
|
}
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
batteryState = uint8(batteries[0].State.Raw)
|
batteryState = uint8(batteries[0].State.Raw)
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -142,7 +144,9 @@ func (client *WebSocketClient) OnOpen(conn *gws.Conn) {
|
|||||||
// OnClose handles WebSocket connection closure.
|
// OnClose handles WebSocket connection closure.
|
||||||
// It logs the closure reason and notifies the connection manager.
|
// It logs the closure reason and notifies the connection manager.
|
||||||
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
func (client *WebSocketClient) OnClose(conn *gws.Conn, err error) {
|
||||||
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
if err != nil {
|
||||||
|
slog.Warn("Connection closed", "err", strings.TrimPrefix(err.Error(), "gws: "))
|
||||||
|
}
|
||||||
client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
client.agent.connectionManager.eventChan <- WebSocketDisconnect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +160,15 @@ func (client *WebSocketClient) OnMessage(conn *gws.Conn, message *gws.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cbor.NewDecoder(message.Data).Decode(client.hubRequest); err != nil {
|
var HubRequest common.HubRequest[cbor.RawMessage]
|
||||||
|
|
||||||
|
err := cbor.Unmarshal(message.Data.Bytes(), &HubRequest)
|
||||||
|
if err != nil {
|
||||||
slog.Error("Error parsing message", "err", err)
|
slog.Error("Error parsing message", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := client.handleHubRequest(client.hubRequest); err != nil {
|
|
||||||
|
if err := client.handleHubRequest(&HubRequest, HubRequest.Id); err != nil {
|
||||||
slog.Error("Error handling message", "err", err)
|
slog.Error("Error handling message", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +181,7 @@ func (client *WebSocketClient) OnPing(conn *gws.Conn, message []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
|
// handleAuthChallenge verifies the authenticity of the hub and returns the system's fingerprint.
|
||||||
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage]) (err error) {
|
func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) (err error) {
|
||||||
var authRequest common.FingerprintRequest
|
var authRequest common.FingerprintRequest
|
||||||
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
if err := cbor.Unmarshal(msg.Data, &authRequest); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -191,12 +199,13 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
|
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||||
response.Hostname = client.agent.systemInfo.Hostname
|
response.Hostname = client.agent.systemInfo.Hostname
|
||||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return client.sendMessage(response)
|
return client.sendResponse(response, requestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySignature verifies the signature of the token using the public keys.
|
// verifySignature verifies the signature of the token using the public keys.
|
||||||
@@ -221,25 +230,17 @@ func (client *WebSocketClient) Close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHubRequest routes the request to the appropriate handler.
|
// handleHubRequest routes the request to the appropriate handler using the handler registry.
|
||||||
// It ensures the hub is verified before processing most requests.
|
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage], requestID *uint32) error {
|
||||||
func (client *WebSocketClient) handleHubRequest(msg *common.HubRequest[cbor.RawMessage]) error {
|
ctx := &HandlerContext{
|
||||||
if !client.hubVerified && msg.Action != common.CheckFingerprint {
|
Client: client,
|
||||||
return errors.New("hub not verified")
|
Agent: client.agent,
|
||||||
|
Request: msg,
|
||||||
|
RequestID: requestID,
|
||||||
|
HubVerified: client.hubVerified,
|
||||||
|
SendResponse: client.sendResponse,
|
||||||
}
|
}
|
||||||
switch msg.Action {
|
return client.agent.handlerRegistry.Handle(ctx)
|
||||||
case common.GetData:
|
|
||||||
return client.sendSystemData()
|
|
||||||
case common.CheckFingerprint:
|
|
||||||
return client.handleAuthChallenge(msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendSystemData gathers and sends current system statistics to the hub.
|
|
||||||
func (client *WebSocketClient) sendSystemData() error {
|
|
||||||
sysStats := client.agent.gatherStats(client.token)
|
|
||||||
return client.sendMessage(sysStats)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
|
// sendMessage encodes the given data to CBOR and sends it as a binary message over the WebSocket connection to the hub.
|
||||||
@@ -248,7 +249,47 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
err = client.Conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
|
if err != nil {
|
||||||
|
// If writing fails (e.g., broken pipe due to network issues),
|
||||||
|
// close the connection to trigger reconnection logic (#1263)
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendResponse sends a response with optional request ID for the new protocol
|
||||||
|
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
||||||
|
if requestID != nil {
|
||||||
|
// New format with ID - use typed fields
|
||||||
|
response := common.AgentResponse{
|
||||||
|
Id: requestID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the appropriate typed field based on data type
|
||||||
|
switch v := data.(type) {
|
||||||
|
case *system.CombinedData:
|
||||||
|
response.SystemData = v
|
||||||
|
case *common.FingerprintResponse:
|
||||||
|
response.Fingerprint = v
|
||||||
|
case string:
|
||||||
|
response.String = &v
|
||||||
|
case map[string]smart.SmartData:
|
||||||
|
response.SmartData = v
|
||||||
|
// case []byte:
|
||||||
|
// response.RawBytes = v
|
||||||
|
// case string:
|
||||||
|
// response.RawBytes = []byte(v)
|
||||||
|
default:
|
||||||
|
// For any other type, convert to error
|
||||||
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.sendMessage(response)
|
||||||
|
} else {
|
||||||
|
// Legacy format - send data directly
|
||||||
|
return client.sendMessage(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserAgent returns one of two User-Agent strings based on current time.
|
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ func TestWebSocketClient_HandleHubRequest(t *testing.T) {
|
|||||||
Data: cbor.RawMessage{},
|
Data: cbor.RawMessage{},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := client.handleHubRequest(hubRequest)
|
err := client.handleHubRequest(hubRequest, nil)
|
||||||
|
|
||||||
if tc.expectError {
|
if tc.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|||||||
66
agent/cpu.go
Normal file
66
agent/cpu.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||||
|
|
||||||
|
// init initializes the CPU monitoring by storing the initial CPU times
|
||||||
|
// for the default 60-second cache interval.
|
||||||
|
func init() {
|
||||||
|
if times, err := cpu.Times(false); err == nil {
|
||||||
|
lastCpuTimes[60000] = times[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
|
||||||
|
// It uses the specified cache time interval to determine the time window for calculation.
|
||||||
|
// Returns the CPU usage percentage (0-100) and any error encountered.
|
||||||
|
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
|
||||||
|
times, err := cpu.Times(false)
|
||||||
|
if err != nil || len(times) == 0 {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
||||||
|
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
||||||
|
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
||||||
|
}
|
||||||
|
delta := calculateBusy(lastCpuTimes[cacheTimeMs], times[0])
|
||||||
|
lastCpuTimes[cacheTimeMs] = times[0]
|
||||||
|
return delta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||||
|
// It computes the ratio of busy time to total time elapsed between t1 and t2,
|
||||||
|
// returning a percentage clamped between 0 and 100.
|
||||||
|
func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
||||||
|
t1All, t1Busy := getAllBusy(t1)
|
||||||
|
t2All, t2Busy := getAllBusy(t2)
|
||||||
|
|
||||||
|
if t2Busy <= t1Busy {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if t2All <= t1All {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
return math.Min(100, math.Max(0, (t2Busy-t1Busy)/(t2All-t1All)*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
||||||
|
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
|
||||||
|
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
|
||||||
|
func getAllBusy(t cpu.TimesStat) (float64, float64) {
|
||||||
|
tot := t.Total()
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
tot -= t.Guest // Linux 2.6.24+
|
||||||
|
tot -= t.GuestNice // Linux 3.2.0+
|
||||||
|
}
|
||||||
|
|
||||||
|
busy := tot - t.Idle - t.Iowait
|
||||||
|
|
||||||
|
return tot, busy
|
||||||
|
}
|
||||||
@@ -37,6 +37,16 @@ func (t *DeltaTracker[K, V]) Set(id K, value V) {
|
|||||||
t.current[id] = value
|
t.current[id] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a copy of the current map.
|
||||||
|
// func (t *DeltaTracker[K, V]) Snapshot() map[K]V {
|
||||||
|
// t.RLock()
|
||||||
|
// defer t.RUnlock()
|
||||||
|
|
||||||
|
// copyMap := make(map[K]V, len(t.current))
|
||||||
|
// maps.Copy(copyMap, t.current)
|
||||||
|
// return copyMap
|
||||||
|
// }
|
||||||
|
|
||||||
// Deltas returns a map of all calculated deltas for the current interval.
|
// Deltas returns a map of all calculated deltas for the current interval.
|
||||||
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
||||||
t.RLock()
|
t.RLock()
|
||||||
@@ -53,6 +63,15 @@ func (t *DeltaTracker[K, V]) Deltas() map[K]V {
|
|||||||
return deltas
|
return deltas
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Previous returns the previously recorded value for the given key, if it exists.
|
||||||
|
func (t *DeltaTracker[K, V]) Previous(id K) (V, bool) {
|
||||||
|
t.RLock()
|
||||||
|
defer t.RUnlock()
|
||||||
|
|
||||||
|
value, ok := t.previous[id]
|
||||||
|
return value, ok
|
||||||
|
}
|
||||||
|
|
||||||
// Delta returns the delta for a single key.
|
// Delta returns the delta for a single key.
|
||||||
// Returns 0 if the key doesn't exist or has no previous value.
|
// Returns 0 if the key doesn't exist or has no previous value.
|
||||||
func (t *DeltaTracker[K, V]) Delta(id K) V {
|
func (t *DeltaTracker[K, V]) Delta(id K) V {
|
||||||
|
|||||||
129
agent/disk.go
129
agent/disk.go
@@ -13,6 +13,19 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
||||||
|
// Returns the device/filesystem part and the custom name part
|
||||||
|
func parseFilesystemEntry(entry string) (device, customName string) {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 {
|
||||||
|
device = strings.TrimSpace(parts[0])
|
||||||
|
customName = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
device = entry
|
||||||
|
}
|
||||||
|
return device, customName
|
||||||
|
}
|
||||||
|
|
||||||
// Sets up the filesystems to monitor for disk usage and I/O.
|
// Sets up the filesystems to monitor for disk usage and I/O.
|
||||||
func (a *Agent) initializeDiskInfo() {
|
func (a *Agent) initializeDiskInfo() {
|
||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
@@ -37,7 +50,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
slog.Debug("Disk I/O", "diskstats", diskIoCounters)
|
||||||
|
|
||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
addFsStat := func(device, mountpoint string, root bool) {
|
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
||||||
var key string
|
var key string
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
key = device
|
key = device
|
||||||
@@ -66,7 +79,11 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint}
|
fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint}
|
||||||
|
if len(customName) > 0 && customName[0] != "" {
|
||||||
|
fsStats.Name = customName[0]
|
||||||
|
}
|
||||||
|
a.fsStats[key] = fsStats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +103,14 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
// Add EXTRA_FILESYSTEMS env var values to fsStats
|
||||||
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists {
|
||||||
for _, fs := range strings.Split(extraFilesystems, ",") {
|
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
||||||
|
// Parse custom name from format: device__customname
|
||||||
|
fs, customName := parseFilesystemEntry(fsEntry)
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs {
|
||||||
addFsStat(p.Device, p.Mountpoint, false)
|
addFsStat(p.Device, p.Mountpoint, false, customName)
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -98,7 +118,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// if not in partitions, test if we can get disk usage
|
// if not in partitions, test if we can get disk usage
|
||||||
if !found {
|
if !found {
|
||||||
if _, err := disk.Usage(fs); err == nil {
|
if _, err := disk.Usage(fs); err == nil {
|
||||||
addFsStat(filepath.Base(fs), fs, false)
|
addFsStat(filepath.Base(fs), fs, false, customName)
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
slog.Error("Invalid filesystem", "name", fs, "err", err)
|
||||||
}
|
}
|
||||||
@@ -120,7 +140,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// Check if device is in /extra-filesystems
|
// Check if device is in /extra-filesystems
|
||||||
if strings.HasPrefix(p.Mountpoint, efPath) {
|
if strings.HasPrefix(p.Mountpoint, efPath) {
|
||||||
addFsStat(p.Device, p.Mountpoint, false)
|
device, customName := parseFilesystemEntry(p.Mountpoint)
|
||||||
|
addFsStat(device, p.Mountpoint, false, customName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +156,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
mountpoint := filepath.Join(efPath, folder.Name())
|
mountpoint := filepath.Join(efPath, folder.Name())
|
||||||
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
slog.Debug("/extra-filesystems", "mountpoint", mountpoint)
|
||||||
if !existingMountpoints[mountpoint] {
|
if !existingMountpoints[mountpoint] {
|
||||||
addFsStat(folder.Name(), mountpoint, false)
|
device, customName := parseFilesystemEntry(folder.Name())
|
||||||
|
addFsStat(device, mountpoint, false, customName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,3 +211,96 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
a.fsNames = append(a.fsNames, device)
|
a.fsNames = append(a.fsNames, device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Updates disk usage statistics for all monitored filesystems
|
||||||
|
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||||
|
// disk usage
|
||||||
|
for _, stats := range a.fsStats {
|
||||||
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
|
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
|
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// reset stats if error (likely unmounted)
|
||||||
|
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
||||||
|
stats.DiskTotal = 0
|
||||||
|
stats.DiskUsed = 0
|
||||||
|
stats.TotalRead = 0
|
||||||
|
stats.TotalWrite = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates disk I/O statistics for all monitored filesystems
|
||||||
|
func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||||
|
// disk i/o (cache-aware per interval)
|
||||||
|
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
||||||
|
// Ensure map for this interval exists
|
||||||
|
if _, ok := a.diskPrev[cacheTimeMs]; !ok {
|
||||||
|
a.diskPrev[cacheTimeMs] = make(map[string]prevDisk)
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for name, d := range ioCounters {
|
||||||
|
stats := a.fsStats[d.Name]
|
||||||
|
if stats == nil {
|
||||||
|
// skip devices not tracked
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous snapshot for this interval and device
|
||||||
|
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||||
|
if !hasPrev {
|
||||||
|
// Seed from agent-level fsStats if present, else seed from current
|
||||||
|
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
|
||||||
|
if prev.at.IsZero() {
|
||||||
|
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
||||||
|
if msElapsed < 100 {
|
||||||
|
// Avoid division by zero or clock issues; update snapshot and continue
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
diskIORead := (d.ReadBytes - prev.readBytes) * 1000 / msElapsed
|
||||||
|
diskIOWrite := (d.WriteBytes - prev.writeBytes) * 1000 / msElapsed
|
||||||
|
readMbPerSecond := bytesToMegabytes(float64(diskIORead))
|
||||||
|
writeMbPerSecond := bytesToMegabytes(float64(diskIOWrite))
|
||||||
|
|
||||||
|
// validate values
|
||||||
|
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||||
|
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
||||||
|
// Reset interval snapshot and seed from current
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
|
// also refresh agent baseline to avoid future negatives
|
||||||
|
a.initializeDiskIoStats(ioCounters)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update per-interval snapshot
|
||||||
|
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||||
|
|
||||||
|
// Update global fsStats baseline for cross-interval correctness
|
||||||
|
stats.Time = now
|
||||||
|
stats.TotalRead = d.ReadBytes
|
||||||
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
stats.DiskReadPs = readMbPerSecond
|
||||||
|
stats.DiskWritePs = writeMbPerSecond
|
||||||
|
stats.DiskReadBytes = diskIORead
|
||||||
|
stats.DiskWriteBytes = diskIOWrite
|
||||||
|
|
||||||
|
if stats.Root {
|
||||||
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
|
systemStats.DiskIO[0] = diskIORead
|
||||||
|
systemStats.DiskIO[1] = diskIOWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
235
agent/disk_test.go
Normal file
235
agent/disk_test.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFilesystemEntry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedFs string
|
||||||
|
expectedName string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple device name",
|
||||||
|
input: "sda1",
|
||||||
|
expectedFs: "sda1",
|
||||||
|
expectedName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "device with custom name",
|
||||||
|
input: "sda1__my-storage",
|
||||||
|
expectedFs: "sda1",
|
||||||
|
expectedName: "my-storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "full device path with custom name",
|
||||||
|
input: "/dev/sdb1__backup-drive",
|
||||||
|
expectedFs: "/dev/sdb1",
|
||||||
|
expectedName: "backup-drive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "NVMe device with custom name",
|
||||||
|
input: "nvme0n1p2__fast-ssd",
|
||||||
|
expectedFs: "nvme0n1p2",
|
||||||
|
expectedName: "fast-ssd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace trimmed",
|
||||||
|
input: " sda2__trimmed-name ",
|
||||||
|
expectedFs: "sda2",
|
||||||
|
expectedName: "trimmed-name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty custom name",
|
||||||
|
input: "sda3__",
|
||||||
|
expectedFs: "sda3",
|
||||||
|
expectedName: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty device name",
|
||||||
|
input: "__just-custom",
|
||||||
|
expectedFs: "",
|
||||||
|
expectedName: "just-custom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple underscores in custom name",
|
||||||
|
input: "sda1__my_custom_drive",
|
||||||
|
expectedFs: "sda1",
|
||||||
|
expectedName: "my_custom_drive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom name with spaces",
|
||||||
|
input: "sda1__My Storage Drive",
|
||||||
|
expectedFs: "sda1",
|
||||||
|
expectedName: "My Storage Drive",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fsEntry := strings.TrimSpace(tt.input)
|
||||||
|
var fs, customName string
|
||||||
|
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
||||||
|
fs = strings.TrimSpace(parts[0])
|
||||||
|
customName = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
fs = fsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedFs, fs)
|
||||||
|
assert.Equal(t, tt.expectedName, customName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||||
|
// Set up environment variables
|
||||||
|
oldEnv := os.Getenv("EXTRA_FILESYSTEMS")
|
||||||
|
defer func() {
|
||||||
|
if oldEnv != "" {
|
||||||
|
os.Setenv("EXTRA_FILESYSTEMS", oldEnv)
|
||||||
|
} else {
|
||||||
|
os.Unsetenv("EXTRA_FILESYSTEMS")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Test with custom names
|
||||||
|
os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
||||||
|
|
||||||
|
// Mock disk partitions (we'll just test the parsing logic)
|
||||||
|
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
||||||
|
testCases := []struct {
|
||||||
|
envValue string
|
||||||
|
expectedFs []string
|
||||||
|
expectedNames map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
envValue: "sda1__my-storage,sdb1__backup-drive",
|
||||||
|
expectedFs: []string{"sda1", "sdb1"},
|
||||||
|
expectedNames: map[string]string{
|
||||||
|
"sda1": "my-storage",
|
||||||
|
"sdb1": "backup-drive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envValue: "sda1,nvme0n1p2__fast-ssd",
|
||||||
|
expectedFs: []string{"sda1", "nvme0n1p2"},
|
||||||
|
expectedNames: map[string]string{
|
||||||
|
"nvme0n1p2": "fast-ssd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
||||||
|
os.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
||||||
|
|
||||||
|
// Create mock partitions that would match our test cases
|
||||||
|
partitions := []disk.PartitionStat{}
|
||||||
|
for _, fs := range tc.expectedFs {
|
||||||
|
if strings.HasPrefix(fs, "/dev/") {
|
||||||
|
partitions = append(partitions, disk.PartitionStat{
|
||||||
|
Device: fs,
|
||||||
|
Mountpoint: fs,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
partitions = append(partitions, disk.PartitionStat{
|
||||||
|
Device: "/dev/" + fs,
|
||||||
|
Mountpoint: "/" + fs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the parsing logic by calling the relevant part
|
||||||
|
// We'll create a simplified version to test just the parsing
|
||||||
|
extraFilesystems := tc.envValue
|
||||||
|
for _, fsEntry := range strings.Split(extraFilesystems, ",") {
|
||||||
|
// Parse the entry
|
||||||
|
fsEntry = strings.TrimSpace(fsEntry)
|
||||||
|
var fs, customName string
|
||||||
|
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
||||||
|
fs = strings.TrimSpace(parts[0])
|
||||||
|
customName = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
fs = fsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the device is in our expected list
|
||||||
|
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
|
||||||
|
|
||||||
|
// Check if custom name should exist
|
||||||
|
if expectedName, exists := tc.expectedNames[fs]; exists {
|
||||||
|
assert.Equal(t, expectedName, customName, "custom name should match expected")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, customName, "custom name should be empty when not expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFsStatsWithCustomNames(t *testing.T) {
|
||||||
|
// Test that FsStats properly stores custom names
|
||||||
|
fsStats := &system.FsStats{
|
||||||
|
Mountpoint: "/mnt/storage",
|
||||||
|
Name: "my-custom-storage",
|
||||||
|
DiskTotal: 100.0,
|
||||||
|
DiskUsed: 50.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "my-custom-storage", fsStats.Name)
|
||||||
|
assert.Equal(t, "/mnt/storage", fsStats.Mountpoint)
|
||||||
|
assert.Equal(t, 100.0, fsStats.DiskTotal)
|
||||||
|
assert.Equal(t, 50.0, fsStats.DiskUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtraFsKeyGeneration(t *testing.T) {
|
||||||
|
// Test the logic for generating ExtraFs keys with custom names
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
deviceName string
|
||||||
|
customName string
|
||||||
|
expectedKey string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with custom name",
|
||||||
|
deviceName: "sda1",
|
||||||
|
customName: "my-storage",
|
||||||
|
expectedKey: "my-storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without custom name",
|
||||||
|
deviceName: "sda1",
|
||||||
|
customName: "",
|
||||||
|
expectedKey: "sda1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty custom name falls back to device",
|
||||||
|
deviceName: "nvme0n1p2",
|
||||||
|
customName: "",
|
||||||
|
expectedKey: "nvme0n1p2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Simulate the key generation logic from agent.go
|
||||||
|
key := tc.deviceName
|
||||||
|
if tc.customName != "" {
|
||||||
|
key = tc.customName
|
||||||
|
}
|
||||||
|
assert.Equal(t, tc.expectedKey, key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
501
agent/docker.go
501
agent/docker.go
@@ -3,8 +3,11 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,17 +17,35 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Docker API timeout in milliseconds
|
||||||
|
dockerTimeoutMs = 2100
|
||||||
|
// Maximum realistic network speed (5 GB/s) to detect bad deltas
|
||||||
|
maxNetworkSpeedBps uint64 = 5e9
|
||||||
|
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
||||||
|
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
||||||
|
// Number of log lines to request when fetching container logs
|
||||||
|
dockerLogsTail = 200
|
||||||
|
// Maximum size of a single log frame (1MB) to prevent memory exhaustion
|
||||||
|
// A single log line larger than 1MB is likely an error or misconfiguration
|
||||||
|
maxLogFrameSize = 1024 * 1024
|
||||||
|
// Maximum total log content size (5MB) to prevent memory exhaustion
|
||||||
|
// This provides a reasonable limit for network transfer and browser rendering
|
||||||
|
maxTotalLogSize = 5 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
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
|
||||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer)
|
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||||
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)
|
||||||
@@ -32,6 +53,17 @@ type dockerManager struct {
|
|||||||
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
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
|
|
||||||
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
|
lastCpuContainer map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu container usage
|
||||||
|
lastCpuSystem map[uint16]map[string]uint64 // cacheTimeMs -> containerId -> last cpu system usage
|
||||||
|
lastCpuReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last read time (Windows)
|
||||||
|
|
||||||
|
// Network delta trackers - one per cache time to avoid interference
|
||||||
|
// cacheTimeMs -> DeltaTracker for network bytes sent/received
|
||||||
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -62,8 +94,8 @@ func (d *dockerManager) dequeue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns stats for all running containers
|
// Returns stats for all running containers with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats, error) {
|
||||||
resp, err := dm.client.Get("http://localhost/containers/json")
|
resp, err := dm.client.Get("http://localhost/containers/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -87,8 +119,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
|
|
||||||
var failedContainers []*container.ApiInfo
|
var failedContainers []*container.ApiInfo
|
||||||
|
|
||||||
for i := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
ctr := dm.apiContainerList[i]
|
|
||||||
ctr.IdShort = ctr.Id[:12]
|
ctr.IdShort = ctr.Id[:12]
|
||||||
dm.validIds[ctr.IdShort] = struct{}{}
|
dm.validIds[ctr.IdShort] = struct{}{}
|
||||||
// check if container is less than 1 minute old (possible restart)
|
// check if container is less than 1 minute old (possible restart)
|
||||||
@@ -98,9 +129,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
dm.deleteContainerStatsSync(ctr.IdShort)
|
dm.deleteContainerStatsSync(ctr.IdShort)
|
||||||
}
|
}
|
||||||
dm.queue()
|
dm.queue()
|
||||||
go func() {
|
go func(ctr *container.ApiInfo) {
|
||||||
defer dm.dequeue()
|
defer dm.dequeue()
|
||||||
err := dm.updateContainerStats(ctr)
|
err := dm.updateContainerStats(ctr, cacheTimeMs)
|
||||||
// if error, delete from map and add to failed list to retry
|
// if error, delete from map and add to failed list to retry
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
@@ -108,7 +139,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
failedContainers = append(failedContainers, ctr)
|
failedContainers = append(failedContainers, ctr)
|
||||||
dm.containerStatsMutex.Unlock()
|
dm.containerStatsMutex.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}(ctr)
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.wg.Wait()
|
dm.wg.Wait()
|
||||||
@@ -119,13 +150,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
for i := range failedContainers {
|
for i := range failedContainers {
|
||||||
ctr := failedContainers[i]
|
ctr := failedContainers[i]
|
||||||
dm.queue()
|
dm.queue()
|
||||||
go func() {
|
go func(ctr *container.ApiInfo) {
|
||||||
defer dm.dequeue()
|
defer dm.dequeue()
|
||||||
err = dm.updateContainerStats(ctr)
|
if err2 := dm.updateContainerStats(ctr, cacheTimeMs); err2 != nil {
|
||||||
if err != nil {
|
slog.Error("Error getting container stats", "err", err2)
|
||||||
slog.Error("Error getting container stats", "err", err)
|
|
||||||
}
|
}
|
||||||
}()
|
}(ctr)
|
||||||
}
|
}
|
||||||
dm.wg.Wait()
|
dm.wg.Wait()
|
||||||
}
|
}
|
||||||
@@ -140,18 +170,191 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepare network trackers for next interval for this cache time
|
||||||
|
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates stats for individual container
|
// initializeCpuTracking initializes CPU tracking maps for a specific cache time interval
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
func (dm *dockerManager) initializeCpuTracking(cacheTimeMs uint16) {
|
||||||
|
// Initialize cache time maps if they don't exist
|
||||||
|
if dm.lastCpuContainer[cacheTimeMs] == nil {
|
||||||
|
dm.lastCpuContainer[cacheTimeMs] = make(map[string]uint64)
|
||||||
|
}
|
||||||
|
if dm.lastCpuSystem[cacheTimeMs] == nil {
|
||||||
|
dm.lastCpuSystem[cacheTimeMs] = make(map[string]uint64)
|
||||||
|
}
|
||||||
|
// Ensure the outer map exists before indexing
|
||||||
|
if dm.lastCpuReadTime == nil {
|
||||||
|
dm.lastCpuReadTime = make(map[uint16]map[string]time.Time)
|
||||||
|
}
|
||||||
|
if dm.lastCpuReadTime[cacheTimeMs] == nil {
|
||||||
|
dm.lastCpuReadTime[cacheTimeMs] = make(map[string]time.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCpuPreviousValues returns previous CPU values for a container and cache time interval
|
||||||
|
func (dm *dockerManager) getCpuPreviousValues(cacheTimeMs uint16, containerId string) (uint64, uint64) {
|
||||||
|
return dm.lastCpuContainer[cacheTimeMs][containerId], dm.lastCpuSystem[cacheTimeMs][containerId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCpuCurrentValues stores current CPU values for a container and cache time interval
|
||||||
|
func (dm *dockerManager) setCpuCurrentValues(cacheTimeMs uint16, containerId string, cpuContainer, cpuSystem uint64) {
|
||||||
|
dm.lastCpuContainer[cacheTimeMs][containerId] = cpuContainer
|
||||||
|
dm.lastCpuSystem[cacheTimeMs][containerId] = cpuSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateMemoryUsage calculates memory usage from Docker API stats
|
||||||
|
func calculateMemoryUsage(apiStats *container.ApiStats, isWindows bool) (uint64, error) {
|
||||||
|
if isWindows {
|
||||||
|
return apiStats.MemoryStats.PrivateWorkingSet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
memCache := apiStats.MemoryStats.Stats.InactiveFile
|
||||||
|
if memCache == 0 {
|
||||||
|
memCache = apiStats.MemoryStats.Stats.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
usedDelta := apiStats.MemoryStats.Usage - memCache
|
||||||
|
if usedDelta <= 0 || usedDelta > maxMemoryUsage {
|
||||||
|
return 0, fmt.Errorf("bad memory stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
return usedDelta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getNetworkTracker returns the DeltaTracker for a specific cache time, creating it if needed
|
||||||
|
func (dm *dockerManager) getNetworkTracker(cacheTimeMs uint16, isSent bool) *deltatracker.DeltaTracker[string, uint64] {
|
||||||
|
var trackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
|
if isSent {
|
||||||
|
trackers = dm.networkSentTrackers
|
||||||
|
} else {
|
||||||
|
trackers = dm.networkRecvTrackers
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackers[cacheTimeMs] == nil {
|
||||||
|
trackers[cacheTimeMs] = deltatracker.NewDeltaTracker[string, uint64]()
|
||||||
|
}
|
||||||
|
|
||||||
|
return trackers[cacheTimeMs]
|
||||||
|
}
|
||||||
|
|
||||||
|
// cycleNetworkDeltasForCacheTime cycles the network delta trackers for a specific cache time
|
||||||
|
func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
|
||||||
|
if dm.networkSentTrackers[cacheTimeMs] != nil {
|
||||||
|
dm.networkSentTrackers[cacheTimeMs].Cycle()
|
||||||
|
}
|
||||||
|
if dm.networkRecvTrackers[cacheTimeMs] != nil {
|
||||||
|
dm.networkRecvTrackers[cacheTimeMs].Cycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
|
||||||
|
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
|
||||||
|
var total_sent, total_recv uint64
|
||||||
|
for _, v := range apiStats.Networks {
|
||||||
|
total_sent += v.TxBytes
|
||||||
|
total_recv += v.RxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the DeltaTracker for this specific cache time
|
||||||
|
sentTracker := dm.getNetworkTracker(cacheTimeMs, true)
|
||||||
|
recvTracker := dm.getNetworkTracker(cacheTimeMs, false)
|
||||||
|
|
||||||
|
// Set current values in the cache-time-specific DeltaTracker
|
||||||
|
sentTracker.Set(ctr.IdShort, total_sent)
|
||||||
|
recvTracker.Set(ctr.IdShort, total_recv)
|
||||||
|
|
||||||
|
// Get deltas (bytes since last measurement)
|
||||||
|
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
|
||||||
|
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
|
||||||
|
|
||||||
|
// Calculate bytes per second independently for Tx and Rx if we have previous data
|
||||||
|
var sent_delta, recv_delta uint64
|
||||||
|
if initialized {
|
||||||
|
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
||||||
|
if millisecondsElapsed > 0 {
|
||||||
|
if sent_delta_raw > 0 {
|
||||||
|
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
|
||||||
|
if sent_delta > maxNetworkSpeedBps {
|
||||||
|
slog.Warn("Bad network delta", "container", name)
|
||||||
|
sent_delta = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recv_delta_raw > 0 {
|
||||||
|
recv_delta = recv_delta_raw * 1000 / millisecondsElapsed
|
||||||
|
if recv_delta > maxNetworkSpeedBps {
|
||||||
|
slog.Warn("Bad network delta", "container", name)
|
||||||
|
recv_delta = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sent_delta, recv_delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateCpuPercentage checks if CPU percentage is within valid range
|
||||||
|
func validateCpuPercentage(cpuPct float64, containerName string) error {
|
||||||
|
if cpuPct > 100 {
|
||||||
|
return fmt.Errorf("%s cpu pct greater than 100: %+v", containerName, cpuPct)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateContainerStatsValues updates the final stats values
|
||||||
|
func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemory uint64, sent_delta, recv_delta uint64, readTime time.Time) {
|
||||||
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
|
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
||||||
|
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
||||||
|
stats.PrevReadTime = readTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDockerStatus(status string) (string, container.DockerHealth) {
|
||||||
|
trimmed := strings.TrimSpace(status)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", container.DockerHealthNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove "About " from status
|
||||||
|
trimmed = strings.Replace(trimmed, "About ", "", 1)
|
||||||
|
|
||||||
|
openIdx := strings.LastIndex(trimmed, "(")
|
||||||
|
if openIdx == -1 || !strings.HasSuffix(trimmed, ")") {
|
||||||
|
return trimmed, container.DockerHealthNone
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText := strings.TrimSpace(trimmed[:openIdx])
|
||||||
|
if statusText == "" {
|
||||||
|
statusText = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
healthText := strings.ToLower(strings.TrimSpace(strings.TrimSuffix(trimmed[openIdx+1:], ")")))
|
||||||
|
// Some Docker statuses include a "health:" prefix inside the parentheses.
|
||||||
|
// Strip it so it maps correctly to the known health states.
|
||||||
|
if colonIdx := strings.IndexRune(healthText, ':'); colonIdx != -1 {
|
||||||
|
prefix := strings.TrimSpace(healthText[:colonIdx])
|
||||||
|
if prefix == "health" || prefix == "health status" {
|
||||||
|
healthText = strings.TrimSpace(healthText[colonIdx+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if health, ok := container.DockerHealthStrings[healthText]; ok {
|
||||||
|
return statusText, health
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed, container.DockerHealthNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates stats for individual container with cache-time-aware delta tracking
|
||||||
|
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
||||||
name := ctr.Names[0][1:]
|
name := ctr.Names[0][1:]
|
||||||
|
|
||||||
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
resp, err := dm.client.Get(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
@@ -159,82 +362,74 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
|
|||||||
// add empty values if they doesn't exist in map
|
// add empty values if they doesn't exist in map
|
||||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||||
if !initialized {
|
if !initialized {
|
||||||
stats = &container.Stats{Name: name}
|
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
||||||
dm.containerStatsMap[ctr.IdShort] = stats
|
dm.containerStatsMap[ctr.IdShort] = stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stats.Id = ctr.IdShort
|
||||||
|
|
||||||
|
statusText, health := parseDockerStatus(ctr.Status)
|
||||||
|
stats.Status = statusText
|
||||||
|
stats.Health = health
|
||||||
|
|
||||||
// reset current stats
|
// reset current stats
|
||||||
stats.Cpu = 0
|
stats.Cpu = 0
|
||||||
stats.Mem = 0
|
stats.Mem = 0
|
||||||
stats.NetworkSent = 0
|
stats.NetworkSent = 0
|
||||||
stats.NetworkRecv = 0
|
stats.NetworkRecv = 0
|
||||||
|
|
||||||
// docker host container stats response
|
|
||||||
// res := dm.getApiStats()
|
|
||||||
// defer dm.putApiStats(res)
|
|
||||||
//
|
|
||||||
|
|
||||||
res := dm.apiStats
|
res := dm.apiStats
|
||||||
res.Networks = nil
|
res.Networks = nil
|
||||||
if err := dm.decode(resp, res); err != nil {
|
if err := dm.decode(resp, res); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculate cpu and memory stats
|
// Initialize CPU tracking for this cache time interval
|
||||||
var usedMemory uint64
|
dm.initializeCpuTracking(cacheTimeMs)
|
||||||
|
|
||||||
|
// Get previous CPU values
|
||||||
|
prevCpuContainer, prevCpuSystem := dm.getCpuPreviousValues(cacheTimeMs, ctr.IdShort)
|
||||||
|
|
||||||
|
// Calculate CPU percentage based on platform
|
||||||
var cpuPct float64
|
var cpuPct float64
|
||||||
|
|
||||||
// store current cpu stats
|
|
||||||
prevCpuContainer, prevCpuSystem := stats.CpuContainer, stats.CpuSystem
|
|
||||||
stats.CpuContainer = res.CPUStats.CPUUsage.TotalUsage
|
|
||||||
stats.CpuSystem = res.CPUStats.SystemUsage
|
|
||||||
|
|
||||||
if dm.isWindows {
|
if dm.isWindows {
|
||||||
usedMemory = res.MemoryStats.PrivateWorkingSet
|
prevRead := dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort]
|
||||||
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, stats.PrevReadTime)
|
cpuPct = res.CalculateCpuPercentWindows(prevCpuContainer, prevRead)
|
||||||
} else {
|
} else {
|
||||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
|
||||||
if res.MemoryStats.Usage == 0 {
|
|
||||||
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
|
||||||
}
|
|
||||||
memCache := res.MemoryStats.Stats.InactiveFile
|
|
||||||
if memCache == 0 {
|
|
||||||
memCache = res.MemoryStats.Stats.Cache
|
|
||||||
}
|
|
||||||
usedMemory = res.MemoryStats.Usage - memCache
|
|
||||||
|
|
||||||
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
cpuPct = res.CalculateCpuPercentLinux(prevCpuContainer, prevCpuSystem)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cpuPct > 100 {
|
// Calculate memory usage
|
||||||
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
usedMemory, err := calculateMemoryUsage(res, dm.isWindows)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s - %w - see https://github.com/henrygd/beszel/issues/144", name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// network
|
// Store current CPU stats for next calculation
|
||||||
|
currentCpuContainer := res.CPUStats.CPUUsage.TotalUsage
|
||||||
|
currentCpuSystem := res.CPUStats.SystemUsage
|
||||||
|
dm.setCpuCurrentValues(cacheTimeMs, ctr.IdShort, currentCpuContainer, currentCpuSystem)
|
||||||
|
|
||||||
|
// Validate CPU percentage
|
||||||
|
if err := validateCpuPercentage(cpuPct, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate network stats using DeltaTracker
|
||||||
|
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
|
||||||
|
|
||||||
|
// Store current network values for legacy compatibility
|
||||||
var total_sent, total_recv uint64
|
var total_sent, total_recv uint64
|
||||||
for _, v := range res.Networks {
|
for _, v := range res.Networks {
|
||||||
total_sent += v.TxBytes
|
total_sent += v.TxBytes
|
||||||
total_recv += v.RxBytes
|
total_recv += v.RxBytes
|
||||||
}
|
}
|
||||||
var sent_delta, recv_delta uint64
|
|
||||||
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
|
|
||||||
if initialized && millisecondsElapsed > 0 {
|
|
||||||
// get bytes per second
|
|
||||||
sent_delta = (total_sent - stats.PrevNet.Sent) * 1000 / millisecondsElapsed
|
|
||||||
recv_delta = (total_recv - stats.PrevNet.Recv) * 1000 / millisecondsElapsed
|
|
||||||
// check for unrealistic network values (> 5GB/s)
|
|
||||||
if sent_delta > 5e9 || recv_delta > 5e9 {
|
|
||||||
slog.Warn("Bad network delta", "container", name)
|
|
||||||
sent_delta, recv_delta = 0, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
stats.PrevNet.Sent, stats.PrevNet.Recv = total_sent, total_recv
|
||||||
|
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
// Update final stats values
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
updateContainerStatsValues(stats, cpuPct, usedMemory, sent_delta, recv_delta, res.Read)
|
||||||
stats.NetworkSent = bytesToMegabytes(float64(sent_delta))
|
// store per-cache-time read time for Windows CPU percent calc
|
||||||
stats.NetworkRecv = bytesToMegabytes(float64(recv_delta))
|
dm.lastCpuReadTime[cacheTimeMs][ctr.IdShort] = res.Read
|
||||||
stats.PrevReadTime = res.Read
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -244,6 +439,15 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
dm.containerStatsMutex.Lock()
|
dm.containerStatsMutex.Lock()
|
||||||
defer dm.containerStatsMutex.Unlock()
|
defer dm.containerStatsMutex.Unlock()
|
||||||
delete(dm.containerStatsMap, id)
|
delete(dm.containerStatsMap, id)
|
||||||
|
for ct := range dm.lastCpuContainer {
|
||||||
|
delete(dm.lastCpuContainer[ct], id)
|
||||||
|
}
|
||||||
|
for ct := range dm.lastCpuSystem {
|
||||||
|
delete(dm.lastCpuSystem[ct], id)
|
||||||
|
}
|
||||||
|
for ct := range dm.lastCpuReadTime {
|
||||||
|
delete(dm.lastCpuReadTime[ct], id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
@@ -283,7 +487,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// configurable timeout
|
// configurable timeout
|
||||||
timeout := time.Millisecond * 2100
|
timeout := time.Millisecond * time.Duration(dockerTimeoutMs)
|
||||||
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
if t, set := GetEnv("DOCKER_TIMEOUT"); set {
|
||||||
timeout, err = time.ParseDuration(t)
|
timeout, err = time.ParseDuration(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -308,6 +512,13 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
apiStats: &container.ApiStats{},
|
apiStats: &container.ApiStats{},
|
||||||
|
|
||||||
|
// Initialize cache-time-aware tracking structures
|
||||||
|
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]),
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
@@ -317,28 +528,49 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check docker version
|
// this can take up to 5 seconds with retry, so run in goroutine
|
||||||
// (versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch)
|
go manager.checkDockerVersion()
|
||||||
|
|
||||||
|
// give version check a chance to complete before returning
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (dm *dockerManager) checkDockerVersion() {
|
||||||
|
var err error
|
||||||
|
var resp *http.Response
|
||||||
var versionInfo struct {
|
var versionInfo struct {
|
||||||
Version string `json:"Version"`
|
Version string `json:"Version"`
|
||||||
}
|
}
|
||||||
resp, err := manager.client.Get("http://localhost/version")
|
const versionMaxTries = 2
|
||||||
|
for i := 1; i <= versionMaxTries; i++ {
|
||||||
|
resp, err = dm.client.Get("http://localhost/version")
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
if i < versionMaxTries {
|
||||||
|
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "error", err)
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return manager
|
return
|
||||||
}
|
}
|
||||||
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
if err := manager.decode(resp, &versionInfo); err != nil {
|
return
|
||||||
return manager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
|
||||||
manager.goodDockerVersion = true
|
dm.goodDockerVersion = true
|
||||||
} else {
|
} else {
|
||||||
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
|
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
|
||||||
}
|
}
|
||||||
|
|
||||||
return manager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
// Decodes Docker API JSON response using a reusable buffer and decoder. Not thread safe.
|
||||||
@@ -368,3 +600,122 @@ func getDockerHost() string {
|
|||||||
}
|
}
|
||||||
return scheme + socks[0]
|
return scheme + socks[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getContainerInfo fetches the inspection data for a container
|
||||||
|
func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) {
|
||||||
|
endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := dm.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("container info request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sensitive environment variables from Config.Env
|
||||||
|
var containerInfo map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&containerInfo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if config, ok := containerInfo["Config"].(map[string]any); ok {
|
||||||
|
delete(config, "Env")
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(containerInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogs fetches the logs for a container
|
||||||
|
func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) {
|
||||||
|
endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := dm.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return "", fmt.Errorf("logs request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
||||||
|
const headerSize = 8
|
||||||
|
var header [headerSize]byte
|
||||||
|
buf := make([]byte, 0, dockerLogsTail*200)
|
||||||
|
totalBytesRead := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
frameLen := binary.BigEndian.Uint32(header[4:])
|
||||||
|
if frameLen == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent memory exhaustion from excessively large frames
|
||||||
|
if frameLen > maxLogFrameSize {
|
||||||
|
return fmt.Errorf("log frame size (%d) exceeds maximum (%d)", frameLen, maxLogFrameSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reading this frame would exceed total log size limit
|
||||||
|
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
||||||
|
// Read and discard remaining data to avoid blocking
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(reader, int64(frameLen)))
|
||||||
|
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = allocateBuffer(buf, int(frameLen))
|
||||||
|
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
if len(buf) > 0 {
|
||||||
|
builder.Write(buf[:min(int(frameLen), len(buf))])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
builder.Write(buf[:frameLen])
|
||||||
|
totalBytesRead += int(frameLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocateBuffer(current []byte, needed int) []byte {
|
||||||
|
if cap(current) >= needed {
|
||||||
|
return current[:needed]
|
||||||
|
}
|
||||||
|
return make([]byte, needed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
1101
agent/docker_test.go
Normal file
1101
agent/docker_test.go
Normal file
File diff suppressed because it is too large
Load Diff
170
agent/gpu.go
170
agent/gpu.go
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -44,6 +45,21 @@ type GPUManager struct {
|
|||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
intelGpuStats bool
|
||||||
GpuDataMap map[string]*system.GPUData
|
GpuDataMap map[string]*system.GPUData
|
||||||
|
// lastAvgData stores the last calculated averages for each GPU
|
||||||
|
// Used when a collection happens before new data arrives (Count == 0)
|
||||||
|
lastAvgData map[string]system.GPUData
|
||||||
|
// Per-cache-key tracking for delta calculations
|
||||||
|
// cacheKey -> gpuId -> snapshot of last count/usage/power values
|
||||||
|
lastSnapshots map[uint16]map[string]*gpuSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpuSnapshot stores the last observed incremental values for delta tracking
|
||||||
|
type gpuSnapshot struct {
|
||||||
|
count uint32
|
||||||
|
usage float64
|
||||||
|
power float64
|
||||||
|
powerPkg float64
|
||||||
|
engines map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// RocmSmiJson represents the JSON structure of rocm-smi output
|
// RocmSmiJson represents the JSON structure of rocm-smi output
|
||||||
@@ -229,47 +245,21 @@ func (gm *GPUManager) parseAmdData(output []byte) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// sums and resets the current GPU utilization data since the last update
|
// GetCurrentData returns GPU utilization data averaged since the last call with this cacheKey
|
||||||
func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
func (gm *GPUManager) GetCurrentData(cacheKey uint16) map[string]system.GPUData {
|
||||||
gm.Lock()
|
gm.Lock()
|
||||||
defer gm.Unlock()
|
defer gm.Unlock()
|
||||||
|
|
||||||
// check for GPUs with the same name
|
gm.initializeSnapshots(cacheKey)
|
||||||
nameCounts := make(map[string]int)
|
nameCounts := gm.countGPUNames()
|
||||||
for _, gpu := range gm.GpuDataMap {
|
|
||||||
nameCounts[gpu.Name]++
|
|
||||||
}
|
|
||||||
|
|
||||||
// copy / reset the data
|
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
// avoid division by zero
|
gpuAvg := gm.calculateGPUAverage(id, gpu, cacheKey)
|
||||||
count := max(gpu.Count, 1)
|
gm.updateInstantaneousValues(&gpuAvg, gpu)
|
||||||
|
gm.storeSnapshot(id, gpu, cacheKey)
|
||||||
|
|
||||||
// average the data
|
// Append id to name if there are multiple GPUs with the same name
|
||||||
gpuAvg := *gpu
|
|
||||||
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpuAvg.Power = twoDecimals(gpu.Power / count)
|
|
||||||
|
|
||||||
// intel gpu stats doesn't provide usage, memory used, or memory total
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
maxEngineUsage := 0.0
|
|
||||||
for name, engine := range gpu.Engines {
|
|
||||||
gpuAvg.Engines[name] = twoDecimals(engine / count)
|
|
||||||
maxEngineUsage = max(maxEngineUsage, engine/count)
|
|
||||||
}
|
|
||||||
gpuAvg.Usage = twoDecimals(maxEngineUsage)
|
|
||||||
} else {
|
|
||||||
gpuAvg.Usage = twoDecimals(gpu.Usage / count)
|
|
||||||
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
|
||||||
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset accumulators in the original gpu data for next collection
|
|
||||||
gpu.Usage, gpu.Power, gpu.Count = gpuAvg.Usage, gpuAvg.Power, 1
|
|
||||||
gpu.Engines = gpuAvg.Engines
|
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||||
}
|
}
|
||||||
@@ -279,6 +269,115 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
return gpuData
|
return gpuData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initializeSnapshots ensures snapshot maps are initialized for the given cache key
|
||||||
|
func (gm *GPUManager) initializeSnapshots(cacheKey uint16) {
|
||||||
|
if gm.lastAvgData == nil {
|
||||||
|
gm.lastAvgData = make(map[string]system.GPUData)
|
||||||
|
}
|
||||||
|
if gm.lastSnapshots == nil {
|
||||||
|
gm.lastSnapshots = make(map[uint16]map[string]*gpuSnapshot)
|
||||||
|
}
|
||||||
|
if gm.lastSnapshots[cacheKey] == nil {
|
||||||
|
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countGPUNames returns a map of GPU names to their occurrence count
|
||||||
|
func (gm *GPUManager) countGPUNames() map[string]int {
|
||||||
|
nameCounts := make(map[string]int)
|
||||||
|
for _, gpu := range gm.GpuDataMap {
|
||||||
|
nameCounts[gpu.Name]++
|
||||||
|
}
|
||||||
|
return nameCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateGPUAverage computes the average GPU metrics since the last snapshot for this cache key
|
||||||
|
func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheKey uint16) system.GPUData {
|
||||||
|
lastSnapshot := gm.lastSnapshots[cacheKey][id]
|
||||||
|
currentCount := uint32(gpu.Count)
|
||||||
|
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||||
|
|
||||||
|
// If no new data arrived, use last known average
|
||||||
|
if deltaCount == 0 {
|
||||||
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new average
|
||||||
|
gpuAvg := *gpu
|
||||||
|
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, lastSnapshot)
|
||||||
|
|
||||||
|
gpuAvg.Power = twoDecimals(deltaPower / float64(deltaCount))
|
||||||
|
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
// make fresh map for averaged engine metrics to avoid mutating
|
||||||
|
// the accumulator map stored in gm.GpuDataMap
|
||||||
|
gpuAvg.Engines = make(map[string]float64, len(gpu.Engines))
|
||||||
|
gpuAvg.Usage = gm.calculateIntelGPUUsage(&gpuAvg, gpu, lastSnapshot, deltaCount)
|
||||||
|
gpuAvg.PowerPkg = twoDecimals(deltaPowerPkg / float64(deltaCount))
|
||||||
|
} else {
|
||||||
|
gpuAvg.Usage = twoDecimals(deltaUsage / float64(deltaCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.lastAvgData[id] = gpuAvg
|
||||||
|
return gpuAvg
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateDeltaCount returns the change in count since the last snapshot
|
||||||
|
func (gm *GPUManager) calculateDeltaCount(currentCount uint32, lastSnapshot *gpuSnapshot) uint32 {
|
||||||
|
if lastSnapshot != nil {
|
||||||
|
return currentCount - lastSnapshot.count
|
||||||
|
}
|
||||||
|
return currentCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateDeltas computes the change in usage, power, and powerPkg since the last snapshot
|
||||||
|
func (gm *GPUManager) calculateDeltas(gpu *system.GPUData, lastSnapshot *gpuSnapshot) (deltaUsage, deltaPower, deltaPowerPkg float64) {
|
||||||
|
if lastSnapshot != nil {
|
||||||
|
return gpu.Usage - lastSnapshot.usage,
|
||||||
|
gpu.Power - lastSnapshot.power,
|
||||||
|
gpu.PowerPkg - lastSnapshot.powerPkg
|
||||||
|
}
|
||||||
|
return gpu.Usage, gpu.Power, gpu.PowerPkg
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateIntelGPUUsage computes Intel GPU usage from engine metrics and returns max engine usage
|
||||||
|
func (gm *GPUManager) calculateIntelGPUUsage(gpuAvg, gpu *system.GPUData, lastSnapshot *gpuSnapshot, deltaCount uint32) float64 {
|
||||||
|
maxEngineUsage := 0.0
|
||||||
|
for name, engine := range gpu.Engines {
|
||||||
|
var deltaEngine float64
|
||||||
|
if lastSnapshot != nil && lastSnapshot.engines != nil {
|
||||||
|
deltaEngine = engine - lastSnapshot.engines[name]
|
||||||
|
} else {
|
||||||
|
deltaEngine = engine
|
||||||
|
}
|
||||||
|
gpuAvg.Engines[name] = twoDecimals(deltaEngine / float64(deltaCount))
|
||||||
|
maxEngineUsage = max(maxEngineUsage, deltaEngine/float64(deltaCount))
|
||||||
|
}
|
||||||
|
return twoDecimals(maxEngineUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateInstantaneousValues updates values that should reflect current state, not averages
|
||||||
|
func (gm *GPUManager) updateInstantaneousValues(gpuAvg *system.GPUData, gpu *system.GPUData) {
|
||||||
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeSnapshot saves the current GPU state for this cache key
|
||||||
|
func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uint16) {
|
||||||
|
snapshot := &gpuSnapshot{
|
||||||
|
count: uint32(gpu.Count),
|
||||||
|
usage: gpu.Usage,
|
||||||
|
power: gpu.Power,
|
||||||
|
powerPkg: gpu.PowerPkg,
|
||||||
|
}
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
snapshot.engines = make(map[string]float64, len(gpu.Engines))
|
||||||
|
maps.Copy(snapshot.engines, gpu.Engines)
|
||||||
|
}
|
||||||
|
gm.lastSnapshots[cacheKey][id] = snapshot
|
||||||
|
}
|
||||||
|
|
||||||
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
// detectGPUs checks for the presence of GPU management tools (nvidia-smi, rocm-smi, tegrastats)
|
||||||
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
// in the system path. It sets the corresponding flags in the GPUManager struct if any of these
|
||||||
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
// tools are found. If none of the tools are found, it returns an error indicating that no GPU
|
||||||
@@ -358,6 +457,9 @@ func (gm *GPUManager) startCollector(command string) {
|
|||||||
|
|
||||||
// NewGPUManager creates and initializes a new GPUManager
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
func NewGPUManager() (*GPUManager, error) {
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
|
if skipGPU, _ := GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
if err := gm.detectGPUs(); err != nil {
|
if err := gm.detectGPUs(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const (
|
|||||||
|
|
||||||
type intelGpuStats struct {
|
type intelGpuStats struct {
|
||||||
PowerGPU float64
|
PowerGPU float64
|
||||||
|
PowerPkg float64
|
||||||
Engines map[string]float64
|
Engines map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gpuData.Power += sample.PowerGPU
|
gpuData.Power += sample.PowerGPU
|
||||||
|
gpuData.PowerPkg += sample.PowerPkg
|
||||||
|
|
||||||
if gpuData.Engines == nil {
|
if gpuData.Engines == nil {
|
||||||
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
gpuData.Engines = make(map[string]float64, len(sample.Engines))
|
||||||
@@ -46,7 +48,7 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
||||||
func (gm *GPUManager) collectIntelStats() error {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
||||||
// Avoid blocking if intel_gpu_top writes to stderr
|
// Avoid blocking if intel_gpu_top writes to stderr
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = io.Discard
|
||||||
@@ -58,23 +60,28 @@ func (gm *GPUManager) collectIntelStats() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we always reap the child to avoid zombies on any return path.
|
// Ensure we always reap the child to avoid zombies on any return path and
|
||||||
|
// propagate a non-zero exit code if no other error was set.
|
||||||
defer func() {
|
defer func() {
|
||||||
// Best-effort close of the pipe (unblock the child if it writes)
|
// Best-effort close of the pipe (unblock the child if it writes)
|
||||||
_ = stdout.Close()
|
_ = stdout.Close()
|
||||||
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
|
||||||
_ = cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
_ = cmd.Wait()
|
if waitErr := cmd.Wait(); err == nil && waitErr != nil {
|
||||||
|
err = waitErr
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
scanner := bufio.NewScanner(stdout)
|
||||||
var header1 string
|
var header1 string
|
||||||
var header2 string
|
|
||||||
var engineNames []string
|
var engineNames []string
|
||||||
var friendlyNames []string
|
var friendlyNames []string
|
||||||
var preEngineCols int
|
var preEngineCols int
|
||||||
var powerIndex int
|
var powerIndex int
|
||||||
|
var hadDataRow bool
|
||||||
|
// skip first data row because it sometimes has erroneous data
|
||||||
|
var skippedFirstDataRow bool
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := strings.TrimSpace(scanner.Text())
|
line := strings.TrimSpace(scanner.Text())
|
||||||
@@ -83,24 +90,34 @@ func (gm *GPUManager) collectIntelStats() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// first header line
|
// first header line
|
||||||
if header1 == "" {
|
if strings.HasPrefix(line, "Freq") {
|
||||||
header1 = line
|
header1 = line
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// second header line
|
// second header line
|
||||||
if header2 == "" {
|
if strings.HasPrefix(line, "req") {
|
||||||
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
|
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
|
||||||
header1, header2 = "x", "x" // don't need these anymore
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data row
|
// Data row
|
||||||
sample := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
if !skippedFirstDataRow {
|
||||||
|
skippedFirstDataRow = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hadDataRow = true
|
||||||
gm.updateIntelFromStats(&sample)
|
gm.updateIntelFromStats(&sample)
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if scanErr := scanner.Err(); scanErr != nil {
|
||||||
return err
|
return scanErr
|
||||||
|
}
|
||||||
|
if !hadDataRow {
|
||||||
|
return errNoValidData
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -145,19 +162,22 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
return engineNames, friendlyNames, powerIndex, preEngineCols
|
return engineNames, friendlyNames, powerIndex, preEngineCols
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats) {
|
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
return sample
|
return sample, errNoValidData
|
||||||
}
|
}
|
||||||
// Make sure row has enough columns for engines
|
// Make sure row has enough columns for engines
|
||||||
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
||||||
return sample
|
return sample, errNoValidData
|
||||||
}
|
}
|
||||||
if powerIndex >= 0 && powerIndex < len(fields) {
|
if powerIndex >= 0 && powerIndex < len(fields) {
|
||||||
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
||||||
sample.PowerGPU = v
|
sample.PowerGPU = v
|
||||||
}
|
}
|
||||||
|
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
|
||||||
|
sample.PowerPkg = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(engineNames) > 0 {
|
if len(engineNames) > 0 {
|
||||||
sample.Engines = make(map[string]float64, len(engineNames))
|
sample.Engines = make(map[string]float64, len(engineNames))
|
||||||
@@ -175,5 +195,5 @@ func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendly
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sample
|
return sample, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentData(t *testing.T) {
|
func TestGetCurrentData(t *testing.T) {
|
||||||
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
|
t.Run("calculates averages with per-cache-key delta tracking", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
"0": {
|
"0": {
|
||||||
@@ -365,7 +365,8 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
result := gm.GetCurrentData()
|
cacheKey := uint16(5000)
|
||||||
|
result := gm.GetCurrentData(cacheKey)
|
||||||
|
|
||||||
// Verify name disambiguation
|
// Verify name disambiguation
|
||||||
assert.Equal(t, "GPU1 0", result["0"].Name)
|
assert.Equal(t, "GPU1 0", result["0"].Name)
|
||||||
@@ -378,13 +379,19 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
||||||
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
// Verify that accumulators in the original map are reset
|
// Verify that accumulators in the original map are NOT reset (they keep growing)
|
||||||
assert.EqualValues(t, float64(1), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
|
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "GPU 0 Count should remain at 2")
|
||||||
assert.EqualValues(t, float64(50.0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
|
assert.EqualValues(t, 100, gm.GpuDataMap["0"].Usage, "GPU 0 Usage should remain at 100")
|
||||||
assert.Equal(t, float64(100.0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
|
assert.Equal(t, 200.0, gm.GpuDataMap["0"].Power, "GPU 0 Power should remain at 200")
|
||||||
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
|
assert.Equal(t, 1.0, gm.GpuDataMap["1"].Count, "GPU 1 Count should remain at 1")
|
||||||
assert.Equal(t, float64(30), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
|
assert.Equal(t, 30.0, gm.GpuDataMap["1"].Usage, "GPU 1 Usage should remain at 30")
|
||||||
assert.Equal(t, float64(60), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
|
assert.Equal(t, 60.0, gm.GpuDataMap["1"].Power, "GPU 1 Power should remain at 60")
|
||||||
|
|
||||||
|
// Verify snapshots were stored for this cache key
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey]["0"])
|
||||||
|
assert.Equal(t, uint32(2), gm.lastSnapshots[cacheKey]["0"].count)
|
||||||
|
assert.Equal(t, 100.0, gm.lastSnapshots[cacheKey]["0"].usage)
|
||||||
|
assert.Equal(t, 200.0, gm.lastSnapshots[cacheKey]["0"].power)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles zero count without panicking", func(t *testing.T) {
|
t.Run("handles zero count without panicking", func(t *testing.T) {
|
||||||
@@ -399,17 +406,543 @@ func TestGetCurrentData(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cacheKey := uint16(5000)
|
||||||
var result map[string]system.GPUData
|
var result map[string]system.GPUData
|
||||||
assert.NotPanics(t, func() {
|
assert.NotPanics(t, func() {
|
||||||
result = gm.GetCurrentData()
|
result = gm.GetCurrentData(cacheKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check that usage and power are 0
|
// Check that usage and power are 0
|
||||||
assert.Equal(t, 0.0, result["0"].Usage)
|
assert.Equal(t, 0.0, result["0"].Usage)
|
||||||
assert.Equal(t, 0.0, result["0"].Power)
|
assert.Equal(t, 0.0, result["0"].Power)
|
||||||
|
|
||||||
// Verify reset count
|
// Verify count remains 0
|
||||||
assert.EqualValues(t, 1, gm.GpuDataMap["0"].Count)
|
assert.EqualValues(t, 0, gm.GpuDataMap["0"].Count)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses last average when no new data arrives", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "TestGPU",
|
||||||
|
Temperature: 55.0,
|
||||||
|
MemoryUsed: 1500,
|
||||||
|
MemoryTotal: 8000,
|
||||||
|
Usage: 100, // Will average to 50
|
||||||
|
Power: 200, // Will average to 100
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := uint16(5000)
|
||||||
|
|
||||||
|
// First collection - should calculate averages and store them
|
||||||
|
result1 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.InDelta(t, 50.0, result1["0"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, result1["0"].Power, 0.01)
|
||||||
|
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should remain at 2")
|
||||||
|
|
||||||
|
// Update temperature but no new usage/power data (count stays same)
|
||||||
|
gm.GpuDataMap["0"].Temperature = 60.0
|
||||||
|
gm.GpuDataMap["0"].MemoryUsed = 1600
|
||||||
|
|
||||||
|
// Second collection - should use last averages since count hasn't changed (delta = 0)
|
||||||
|
result2 := gm.GetCurrentData(cacheKey)
|
||||||
|
assert.InDelta(t, 50.0, result2["0"].Usage, 0.01, "Should use last average")
|
||||||
|
assert.InDelta(t, 100.0, result2["0"].Power, 0.01, "Should use last average")
|
||||||
|
assert.InDelta(t, 60.0, result2["0"].Temperature, 0.01, "Should use current temperature")
|
||||||
|
assert.InDelta(t, 1600.0, result2["0"].MemoryUsed, 0.01, "Should use current memory")
|
||||||
|
assert.EqualValues(t, 2, gm.GpuDataMap["0"].Count, "Count should still be 2")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("tracks separate averages per cache key", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "TestGPU",
|
||||||
|
Temperature: 55.0,
|
||||||
|
MemoryUsed: 1500,
|
||||||
|
MemoryTotal: 8000,
|
||||||
|
Usage: 100, // Initial: 100 over 2 counts = 50 avg
|
||||||
|
Power: 200, // Initial: 200 over 2 counts = 100 avg
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey1 := uint16(5000)
|
||||||
|
cacheKey2 := uint16(10000)
|
||||||
|
|
||||||
|
// First check with cacheKey1 - baseline
|
||||||
|
result1 := gm.GetCurrentData(cacheKey1)
|
||||||
|
assert.InDelta(t, 50.0, result1["0"].Usage, 0.01, "CacheKey1: Initial average should be 50")
|
||||||
|
assert.InDelta(t, 100.0, result1["0"].Power, 0.01, "CacheKey1: Initial average should be 100")
|
||||||
|
|
||||||
|
// Simulate GPU activity - accumulate more data
|
||||||
|
gm.GpuDataMap["0"].Usage += 60 // Now total: 160
|
||||||
|
gm.GpuDataMap["0"].Power += 150 // Now total: 350
|
||||||
|
gm.GpuDataMap["0"].Count += 3 // Now total: 5
|
||||||
|
|
||||||
|
// Check with cacheKey1 again - should get delta since last cacheKey1 check
|
||||||
|
result2 := gm.GetCurrentData(cacheKey1)
|
||||||
|
assert.InDelta(t, 20.0, result2["0"].Usage, 0.01, "CacheKey1: Delta average should be 60/3 = 20")
|
||||||
|
assert.InDelta(t, 50.0, result2["0"].Power, 0.01, "CacheKey1: Delta average should be 150/3 = 50")
|
||||||
|
|
||||||
|
// Check with cacheKey2 for the first time - should get average since beginning
|
||||||
|
result3 := gm.GetCurrentData(cacheKey2)
|
||||||
|
assert.InDelta(t, 32.0, result3["0"].Usage, 0.01, "CacheKey2: Total average should be 160/5 = 32")
|
||||||
|
assert.InDelta(t, 70.0, result3["0"].Power, 0.01, "CacheKey2: Total average should be 350/5 = 70")
|
||||||
|
|
||||||
|
// Simulate more GPU activity
|
||||||
|
gm.GpuDataMap["0"].Usage += 80 // Now total: 240
|
||||||
|
gm.GpuDataMap["0"].Power += 160 // Now total: 510
|
||||||
|
gm.GpuDataMap["0"].Count += 2 // Now total: 7
|
||||||
|
|
||||||
|
// Check with cacheKey1 - should get delta since last cacheKey1 check
|
||||||
|
result4 := gm.GetCurrentData(cacheKey1)
|
||||||
|
assert.InDelta(t, 40.0, result4["0"].Usage, 0.01, "CacheKey1: New delta average should be 80/2 = 40")
|
||||||
|
assert.InDelta(t, 80.0, result4["0"].Power, 0.01, "CacheKey1: New delta average should be 160/2 = 80")
|
||||||
|
|
||||||
|
// Check with cacheKey2 - should get delta since last cacheKey2 check
|
||||||
|
result5 := gm.GetCurrentData(cacheKey2)
|
||||||
|
assert.InDelta(t, 40.0, result5["0"].Usage, 0.01, "CacheKey2: Delta average should be 80/2 = 40")
|
||||||
|
assert.InDelta(t, 80.0, result5["0"].Power, 0.01, "CacheKey2: Delta average should be 160/2 = 80")
|
||||||
|
|
||||||
|
// Verify snapshots exist for both cache keys
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey1])
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey2])
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey1]["0"])
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey2]["0"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDeltaCount(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
|
||||||
|
t.Run("with no previous snapshot", func(t *testing.T) {
|
||||||
|
delta := gm.calculateDeltaCount(10, nil)
|
||||||
|
assert.Equal(t, uint32(10), delta, "Should return current count when no snapshot exists")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with previous snapshot", func(t *testing.T) {
|
||||||
|
snapshot := &gpuSnapshot{count: 5}
|
||||||
|
delta := gm.calculateDeltaCount(15, snapshot)
|
||||||
|
assert.Equal(t, uint32(10), delta, "Should return difference between current and snapshot")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with same count", func(t *testing.T) {
|
||||||
|
snapshot := &gpuSnapshot{count: 10}
|
||||||
|
delta := gm.calculateDeltaCount(10, snapshot)
|
||||||
|
assert.Equal(t, uint32(0), delta, "Should return zero when count hasn't changed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateDeltas(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
|
||||||
|
t.Run("with no previous snapshot", func(t *testing.T) {
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Usage: 100.5,
|
||||||
|
Power: 250.75,
|
||||||
|
PowerPkg: 300.25,
|
||||||
|
}
|
||||||
|
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, nil)
|
||||||
|
assert.Equal(t, 100.5, deltaUsage)
|
||||||
|
assert.Equal(t, 250.75, deltaPower)
|
||||||
|
assert.Equal(t, 300.25, deltaPowerPkg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with previous snapshot", func(t *testing.T) {
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Usage: 150.5,
|
||||||
|
Power: 300.75,
|
||||||
|
PowerPkg: 400.25,
|
||||||
|
}
|
||||||
|
snapshot := &gpuSnapshot{
|
||||||
|
usage: 100.5,
|
||||||
|
power: 250.75,
|
||||||
|
powerPkg: 300.25,
|
||||||
|
}
|
||||||
|
deltaUsage, deltaPower, deltaPowerPkg := gm.calculateDeltas(gpu, snapshot)
|
||||||
|
assert.InDelta(t, 50.0, deltaUsage, 0.01)
|
||||||
|
assert.InDelta(t, 50.0, deltaPower, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, deltaPowerPkg, 0.01)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateIntelGPUUsage(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
|
||||||
|
t.Run("with no previous snapshot", func(t *testing.T) {
|
||||||
|
gpuAvg := &system.GPUData{
|
||||||
|
Engines: make(map[string]float64),
|
||||||
|
}
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 80.0,
|
||||||
|
"Video": 40.0,
|
||||||
|
"Compute": 60.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, nil, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, 40.0, maxUsage, "Should return max engine usage (80/2=40)")
|
||||||
|
assert.Equal(t, 40.0, gpuAvg.Engines["Render/3D"])
|
||||||
|
assert.Equal(t, 20.0, gpuAvg.Engines["Video"])
|
||||||
|
assert.Equal(t, 30.0, gpuAvg.Engines["Compute"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with previous snapshot", func(t *testing.T) {
|
||||||
|
gpuAvg := &system.GPUData{
|
||||||
|
Engines: make(map[string]float64),
|
||||||
|
}
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 180.0,
|
||||||
|
"Video": 100.0,
|
||||||
|
"Compute": 140.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
snapshot := &gpuSnapshot{
|
||||||
|
engines: map[string]float64{
|
||||||
|
"Render/3D": 80.0,
|
||||||
|
"Video": 40.0,
|
||||||
|
"Compute": 60.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 5)
|
||||||
|
|
||||||
|
// Deltas: Render/3D=100, Video=60, Compute=80 over 5 counts
|
||||||
|
assert.Equal(t, 20.0, maxUsage, "Should return max engine delta (100/5=20)")
|
||||||
|
assert.Equal(t, 20.0, gpuAvg.Engines["Render/3D"])
|
||||||
|
assert.Equal(t, 12.0, gpuAvg.Engines["Video"])
|
||||||
|
assert.Equal(t, 16.0, gpuAvg.Engines["Compute"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles missing engine in snapshot", func(t *testing.T) {
|
||||||
|
gpuAvg := &system.GPUData{
|
||||||
|
Engines: make(map[string]float64),
|
||||||
|
}
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 100.0,
|
||||||
|
"NewEngine": 50.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
snapshot := &gpuSnapshot{
|
||||||
|
engines: map[string]float64{
|
||||||
|
"Render/3D": 80.0,
|
||||||
|
// NewEngine doesn't exist in snapshot
|
||||||
|
},
|
||||||
|
}
|
||||||
|
maxUsage := gm.calculateIntelGPUUsage(gpuAvg, gpu, snapshot, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, 25.0, maxUsage)
|
||||||
|
assert.Equal(t, 10.0, gpuAvg.Engines["Render/3D"], "Should use delta for existing engine")
|
||||||
|
assert.Equal(t, 25.0, gpuAvg.Engines["NewEngine"], "Should use full value for new engine")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateInstantaneousValues(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
|
||||||
|
t.Run("updates temperature, memory used and total", func(t *testing.T) {
|
||||||
|
gpuAvg := &system.GPUData{
|
||||||
|
Temperature: 50.123,
|
||||||
|
MemoryUsed: 1000.456,
|
||||||
|
MemoryTotal: 8000.789,
|
||||||
|
}
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Temperature: 75.567,
|
||||||
|
MemoryUsed: 2500.891,
|
||||||
|
MemoryTotal: 8192.234,
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.updateInstantaneousValues(gpuAvg, gpu)
|
||||||
|
|
||||||
|
assert.Equal(t, 75.57, gpuAvg.Temperature, "Should update and round temperature")
|
||||||
|
assert.Equal(t, 2500.89, gpuAvg.MemoryUsed, "Should update and round memory used")
|
||||||
|
assert.Equal(t, 8192.23, gpuAvg.MemoryTotal, "Should update and round memory total")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoreSnapshot(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: make(map[uint16]map[string]*gpuSnapshot),
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("stores standard GPU snapshot", func(t *testing.T) {
|
||||||
|
cacheKey := uint16(5000)
|
||||||
|
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Count: 10.0,
|
||||||
|
Usage: 150.5,
|
||||||
|
Power: 250.75,
|
||||||
|
PowerPkg: 300.25,
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.storeSnapshot("0", gpu, cacheKey)
|
||||||
|
|
||||||
|
snapshot := gm.lastSnapshots[cacheKey]["0"]
|
||||||
|
assert.NotNil(t, snapshot)
|
||||||
|
assert.Equal(t, uint32(10), snapshot.count)
|
||||||
|
assert.Equal(t, 150.5, snapshot.usage)
|
||||||
|
assert.Equal(t, 250.75, snapshot.power)
|
||||||
|
assert.Equal(t, 300.25, snapshot.powerPkg)
|
||||||
|
assert.Nil(t, snapshot.engines, "Should not have engines for standard GPU")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stores Intel GPU snapshot with engines", func(t *testing.T) {
|
||||||
|
cacheKey := uint16(10000)
|
||||||
|
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Count: 5.0,
|
||||||
|
Usage: 100.0,
|
||||||
|
Power: 200.0,
|
||||||
|
PowerPkg: 250.0,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 80.0,
|
||||||
|
"Video": 40.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.storeSnapshot("0", gpu, cacheKey)
|
||||||
|
|
||||||
|
snapshot := gm.lastSnapshots[cacheKey]["0"]
|
||||||
|
assert.NotNil(t, snapshot)
|
||||||
|
assert.Equal(t, uint32(5), snapshot.count)
|
||||||
|
assert.NotNil(t, snapshot.engines, "Should have engines for Intel GPU")
|
||||||
|
assert.Equal(t, 80.0, snapshot.engines["Render/3D"])
|
||||||
|
assert.Equal(t, 40.0, snapshot.engines["Video"])
|
||||||
|
assert.Len(t, snapshot.engines, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overwrites existing snapshot", func(t *testing.T) {
|
||||||
|
cacheKey := uint16(5000)
|
||||||
|
gm.lastSnapshots[cacheKey] = make(map[string]*gpuSnapshot)
|
||||||
|
|
||||||
|
// Store initial snapshot
|
||||||
|
gpu1 := &system.GPUData{Count: 5.0, Usage: 100.0, Power: 200.0}
|
||||||
|
gm.storeSnapshot("0", gpu1, cacheKey)
|
||||||
|
|
||||||
|
// Store updated snapshot
|
||||||
|
gpu2 := &system.GPUData{Count: 10.0, Usage: 250.0, Power: 400.0}
|
||||||
|
gm.storeSnapshot("0", gpu2, cacheKey)
|
||||||
|
|
||||||
|
snapshot := gm.lastSnapshots[cacheKey]["0"]
|
||||||
|
assert.Equal(t, uint32(10), snapshot.count, "Should overwrite previous count")
|
||||||
|
assert.Equal(t, 250.0, snapshot.usage, "Should overwrite previous usage")
|
||||||
|
assert.Equal(t, 400.0, snapshot.power, "Should overwrite previous power")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCountGPUNames(t *testing.T) {
|
||||||
|
t.Run("returns empty map for no GPUs", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
|
}
|
||||||
|
counts := gm.countGPUNames()
|
||||||
|
assert.Empty(t, counts)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("counts unique GPU names", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {Name: "GPU A"},
|
||||||
|
"1": {Name: "GPU B"},
|
||||||
|
"2": {Name: "GPU C"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
counts := gm.countGPUNames()
|
||||||
|
assert.Equal(t, 1, counts["GPU A"])
|
||||||
|
assert.Equal(t, 1, counts["GPU B"])
|
||||||
|
assert.Equal(t, 1, counts["GPU C"])
|
||||||
|
assert.Len(t, counts, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("counts duplicate GPU names", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {Name: "RTX 4090"},
|
||||||
|
"1": {Name: "RTX 4090"},
|
||||||
|
"2": {Name: "RTX 4090"},
|
||||||
|
"3": {Name: "RTX 3080"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
counts := gm.countGPUNames()
|
||||||
|
assert.Equal(t, 3, counts["RTX 4090"])
|
||||||
|
assert.Equal(t, 1, counts["RTX 3080"])
|
||||||
|
assert.Len(t, counts, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitializeSnapshots(t *testing.T) {
|
||||||
|
t.Run("initializes all maps from scratch", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
cacheKey := uint16(5000)
|
||||||
|
|
||||||
|
gm.initializeSnapshots(cacheKey)
|
||||||
|
|
||||||
|
assert.NotNil(t, gm.lastAvgData)
|
||||||
|
assert.NotNil(t, gm.lastSnapshots)
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("initializes only missing maps", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastAvgData: make(map[string]system.GPUData),
|
||||||
|
}
|
||||||
|
cacheKey := uint16(5000)
|
||||||
|
|
||||||
|
gm.initializeSnapshots(cacheKey)
|
||||||
|
|
||||||
|
assert.NotNil(t, gm.lastAvgData, "Should preserve existing lastAvgData")
|
||||||
|
assert.NotNil(t, gm.lastSnapshots)
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[cacheKey])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("adds new cache key to existing snapshots", func(t *testing.T) {
|
||||||
|
existingKey := uint16(5000)
|
||||||
|
newKey := uint16(10000)
|
||||||
|
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
existingKey: {"0": {count: 10}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gm.initializeSnapshots(newKey)
|
||||||
|
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[existingKey], "Should preserve existing cache key")
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[newKey], "Should add new cache key")
|
||||||
|
assert.NotNil(t, gm.lastSnapshots[existingKey]["0"], "Should preserve existing snapshot data")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateGPUAverage(t *testing.T) {
|
||||||
|
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {
|
||||||
|
"0": {count: 10, usage: 100, power: 200},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastAvgData: map[string]system.GPUData{
|
||||||
|
"0": {Usage: 50.0, Power: 100.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Count: 10.0, // Same as snapshot, so delta = 0
|
||||||
|
Usage: 100.0,
|
||||||
|
Power: 200.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, 50.0, result.Usage, "Should return cached average")
|
||||||
|
assert.Equal(t, 100.0, result.Power, "Should return cached average")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("calculates average for standard GPU", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {},
|
||||||
|
},
|
||||||
|
lastAvgData: make(map[string]system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Name: "Test GPU",
|
||||||
|
Count: 4.0,
|
||||||
|
Usage: 200.0, // 200 / 4 = 50
|
||||||
|
Power: 400.0, // 400 / 4 = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, 50.0, result.Usage)
|
||||||
|
assert.Equal(t, 100.0, result.Power)
|
||||||
|
assert.Equal(t, "Test GPU", result.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("calculates average for Intel GPU with engines", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {},
|
||||||
|
},
|
||||||
|
lastAvgData: make(map[string]system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Name: "Intel GPU",
|
||||||
|
Count: 5.0,
|
||||||
|
Power: 500.0,
|
||||||
|
PowerPkg: 600.0,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"Render/3D": 100.0, // 100 / 5 = 20
|
||||||
|
"Video": 50.0, // 50 / 5 = 10
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, 100.0, result.Power)
|
||||||
|
assert.Equal(t, 120.0, result.PowerPkg)
|
||||||
|
assert.Equal(t, 20.0, result.Usage, "Should use max engine usage")
|
||||||
|
assert.Equal(t, 20.0, result.Engines["Render/3D"])
|
||||||
|
assert.Equal(t, 10.0, result.Engines["Video"])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("calculates delta from previous snapshot", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {
|
||||||
|
"0": {
|
||||||
|
count: 2,
|
||||||
|
usage: 50.0,
|
||||||
|
power: 100.0,
|
||||||
|
powerPkg: 120.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lastAvgData: make(map[string]system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Name: "Test GPU",
|
||||||
|
Count: 7.0, // Delta = 7 - 2 = 5
|
||||||
|
Usage: 200.0, // Delta = 200 - 50 = 150, avg = 150/5 = 30
|
||||||
|
Power: 350.0, // Delta = 350 - 100 = 250, avg = 250/5 = 50
|
||||||
|
PowerPkg: 420.0, // Delta = 420 - 120 = 300, avg = 300/5 = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, 30.0, result.Usage)
|
||||||
|
assert.Equal(t, 50.0, result.Power)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stores result in lastAvgData", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
5000: {},
|
||||||
|
},
|
||||||
|
lastAvgData: make(map[string]system.GPUData),
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := &system.GPUData{
|
||||||
|
Count: 2.0,
|
||||||
|
Usage: 100.0,
|
||||||
|
Power: 200.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, result, gm.lastAvgData["0"], "Should store calculated average")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +1298,8 @@ func TestAccumulation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify average calculation in GetCurrentData
|
// Verify average calculation in GetCurrentData
|
||||||
result := gm.GetCurrentData()
|
cacheKey := uint16(5000)
|
||||||
|
result := gm.GetCurrentData(cacheKey)
|
||||||
for id, expected := range tt.expectedValues {
|
for id, expected := range tt.expectedValues {
|
||||||
gpu, exists := result[id]
|
gpu, exists := result[id]
|
||||||
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
|
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
|
||||||
@@ -778,16 +1312,16 @@ func TestAccumulation(t *testing.T) {
|
|||||||
assert.EqualValues(t, expected.avgPower, gpu.Power, "Average power in GetCurrentData should match")
|
assert.EqualValues(t, expected.avgPower, gpu.Power, "Average power in GetCurrentData should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that accumulators in the original map are reset
|
// Verify that accumulators in the original map are NOT reset (they keep growing)
|
||||||
for id, expected := range tt.expectedValues {
|
for id, expected := range tt.expectedValues {
|
||||||
gpu, exists := gm.GpuDataMap[id]
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
||||||
if !exists {
|
if !exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
assert.EqualValues(t, 1, gpu.Count, "Count should be reset for GPU ID %s", id)
|
assert.EqualValues(t, expected.count, gpu.Count, "Count should remain at accumulated value for GPU ID %s", id)
|
||||||
assert.EqualValues(t, expected.avgUsage, gpu.Usage, "Usage should be reset for GPU ID %s", id)
|
assert.EqualValues(t, expected.usage, gpu.Usage, "Usage should remain at accumulated value for GPU ID %s", id)
|
||||||
assert.EqualValues(t, expected.avgPower, gpu.Power, "Power should be reset for GPU ID %s", id)
|
assert.EqualValues(t, expected.power, gpu.Power, "Power should remain at accumulated value for GPU ID %s", id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -849,13 +1383,15 @@ func TestIntelCollectorStreaming(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
os.Setenv("PATH", dir)
|
os.Setenv("PATH", dir)
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that prints -l format with two samples and exits
|
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
script := `#!/bin/sh
|
script := `#!/bin/sh
|
||||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
|
||||||
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
|
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
|
||||||
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
|
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
|
||||||
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"`
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1"
|
||||||
|
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -864,21 +1400,22 @@ echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00
|
|||||||
GpuDataMap: make(map[string]*system.GPUData),
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the collector once; it should read two samples and return
|
// Run the collector once; it should read four samples but skip the first and return
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
t.Fatalf("collectIntelStats error: %v", err)
|
t.Fatalf("collectIntelStats error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["0"]
|
gpu := gm.GpuDataMap["0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
// Power should be sum of samples: 1.5 + 2.0 = 3.5
|
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
||||||
assert.EqualValues(t, 3.5, gpu.Power)
|
assert.EqualValues(t, 6.0, gpu.Power)
|
||||||
// Engines aggregated
|
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
||||||
assert.EqualValues(t, 12.34, gpu.Engines["Render/3D"])
|
// Engines aggregated from samples 2-4
|
||||||
assert.EqualValues(t, 5.0, gpu.Engines["Video"])
|
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
|
||||||
assert.EqualValues(t, 0.0, gpu.Engines["Blitter"]) // BCS is zero in both samples
|
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
|
||||||
// Count should be 2 samples
|
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
|
||||||
assert.Equal(t, float64(2), gpu.Count)
|
// Count should be 3 samples (first is skipped)
|
||||||
|
assert.Equal(t, float64(3), gpu.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntelHeaders(t *testing.T) {
|
func TestParseIntelHeaders(t *testing.T) {
|
||||||
@@ -970,6 +1507,7 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
preEngineCols int
|
preEngineCols int
|
||||||
wantPowerGPU float64
|
wantPowerGPU float64
|
||||||
wantEngines map[string]float64
|
wantEngines map[string]float64
|
||||||
|
wantErr error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic data with power and engines",
|
name: "basic data with power and engines",
|
||||||
@@ -1022,6 +1560,7 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
preEngineCols: 8,
|
preEngineCols: 8,
|
||||||
wantPowerGPU: 0.0,
|
wantPowerGPU: 0.0,
|
||||||
wantEngines: nil, // empty sample returned
|
wantEngines: nil, // empty sample returned
|
||||||
|
wantErr: errNoValidData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty line",
|
name: "empty line",
|
||||||
@@ -1032,6 +1571,7 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
preEngineCols: 8,
|
preEngineCols: 8,
|
||||||
wantPowerGPU: 0.0,
|
wantPowerGPU: 0.0,
|
||||||
wantEngines: nil,
|
wantEngines: nil,
|
||||||
|
wantErr: errNoValidData,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "data with invalid power value",
|
name: "data with invalid power value",
|
||||||
@@ -1076,7 +1616,8 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
gm := &GPUManager{}
|
gm := &GPUManager{}
|
||||||
sample := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
|
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
|
||||||
|
assert.Equal(t, tt.wantErr, err)
|
||||||
|
|
||||||
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
|
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
|
||||||
assert.Equal(t, tt.wantEngines, sample.Engines)
|
assert.Equal(t, tt.wantEngines, sample.Engines)
|
||||||
|
|||||||
176
agent/handlers.go
Normal file
176
agent/handlers.go
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandlerContext provides context for request handlers
|
||||||
|
type HandlerContext struct {
|
||||||
|
Client *WebSocketClient
|
||||||
|
Agent *Agent
|
||||||
|
Request *common.HubRequest[cbor.RawMessage]
|
||||||
|
RequestID *uint32
|
||||||
|
HubVerified bool
|
||||||
|
// SendResponse abstracts how a handler sends responses (WS or SSH)
|
||||||
|
SendResponse func(data any, requestID *uint32) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestHandler defines the interface for handling specific websocket request types
|
||||||
|
type RequestHandler interface {
|
||||||
|
// Handle processes the request and returns an error if unsuccessful
|
||||||
|
Handle(hctx *HandlerContext) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responder sends handler responses back to the hub (over WS or SSH)
|
||||||
|
type Responder interface {
|
||||||
|
SendResponse(data any, requestID *uint32) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandlerRegistry manages the mapping between actions and their handlers
|
||||||
|
type HandlerRegistry struct {
|
||||||
|
handlers map[common.WebSocketAction]RequestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlerRegistry creates a new handler registry with default handlers
|
||||||
|
func NewHandlerRegistry() *HandlerRegistry {
|
||||||
|
registry := &HandlerRegistry{
|
||||||
|
handlers: make(map[common.WebSocketAction]RequestHandler),
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.Register(common.GetData, &GetDataHandler{})
|
||||||
|
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
||||||
|
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||||
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
|
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers a handler for a specific action type
|
||||||
|
func (hr *HandlerRegistry) Register(action common.WebSocketAction, handler RequestHandler) {
|
||||||
|
hr.handlers[action] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle routes the request to the appropriate handler
|
||||||
|
func (hr *HandlerRegistry) Handle(hctx *HandlerContext) error {
|
||||||
|
handler, exists := hr.handlers[hctx.Request.Action]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("unknown action: %d", hctx.Request.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verification requirement - default to requiring verification
|
||||||
|
if hctx.Request.Action != common.CheckFingerprint && !hctx.HubVerified {
|
||||||
|
return errors.New("hub not verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log handler execution for debugging
|
||||||
|
// slog.Debug("Executing handler", "action", hctx.Request.Action)
|
||||||
|
|
||||||
|
return handler.Handle(hctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHandler returns the handler for a specific action
|
||||||
|
func (hr *HandlerRegistry) GetHandler(action common.WebSocketAction) (RequestHandler, bool) {
|
||||||
|
handler, exists := hr.handlers[action]
|
||||||
|
return handler, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetDataHandler handles system data requests
|
||||||
|
type GetDataHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
var options common.DataRequestOptions
|
||||||
|
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||||
|
|
||||||
|
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
||||||
|
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// CheckFingerprintHandler handles authentication challenges
|
||||||
|
type CheckFingerprintHandler struct{}
|
||||||
|
|
||||||
|
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetContainerLogsHandler handles container log requests
|
||||||
|
type GetContainerLogsHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetContainerLogsHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
if hctx.Agent.dockerManager == nil {
|
||||||
|
return hctx.SendResponse("", hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req common.ContainerLogsRequest
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
logContent, err := hctx.Agent.dockerManager.getLogs(ctx, req.ContainerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hctx.SendResponse(logContent, hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetContainerInfoHandler handles container info requests
|
||||||
|
type GetContainerInfoHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetContainerInfoHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
if hctx.Agent.dockerManager == nil {
|
||||||
|
return hctx.SendResponse("", hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req common.ContainerInfoRequest
|
||||||
|
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
info, err := hctx.Agent.dockerManager.getContainerInfo(ctx, req.ContainerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return hctx.SendResponse(string(info), hctx.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// GetSmartDataHandler handles SMART data requests
|
||||||
|
type GetSmartDataHandler struct{}
|
||||||
|
|
||||||
|
func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
||||||
|
if hctx.Agent.smartManager == nil {
|
||||||
|
// return empty map to indicate no data
|
||||||
|
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
||||||
|
}
|
||||||
|
if err := hctx.Agent.smartManager.Refresh(); err != nil {
|
||||||
|
slog.Debug("smart refresh failed", "err", err)
|
||||||
|
}
|
||||||
|
data := hctx.Agent.smartManager.GetCurrentData()
|
||||||
|
return hctx.SendResponse(data, hctx.RequestID)
|
||||||
|
}
|
||||||
112
agent/handlers_test.go
Normal file
112
agent/handlers_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockHandler for testing
|
||||||
|
type MockHandler struct {
|
||||||
|
requiresVerification bool
|
||||||
|
description string
|
||||||
|
handleFunc func(ctx *HandlerContext) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockHandler) Handle(ctx *HandlerContext) error {
|
||||||
|
if m.handleFunc != nil {
|
||||||
|
return m.handleFunc(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockHandler) RequiresVerification() bool {
|
||||||
|
return m.requiresVerification
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlerRegistry tests the handler registry functionality
|
||||||
|
func TestHandlerRegistry(t *testing.T) {
|
||||||
|
t.Run("default registration", func(t *testing.T) {
|
||||||
|
registry := NewHandlerRegistry()
|
||||||
|
|
||||||
|
// Check default handlers are registered
|
||||||
|
getDataHandler, exists := registry.GetHandler(common.GetData)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.IsType(t, &GetDataHandler{}, getDataHandler)
|
||||||
|
|
||||||
|
fingerprintHandler, exists := registry.GetHandler(common.CheckFingerprint)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.IsType(t, &CheckFingerprintHandler{}, fingerprintHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom handler registration", func(t *testing.T) {
|
||||||
|
registry := NewHandlerRegistry()
|
||||||
|
mockHandler := &MockHandler{
|
||||||
|
requiresVerification: true,
|
||||||
|
description: "Test handler",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a custom handler for a mock action
|
||||||
|
const mockAction common.WebSocketAction = 99
|
||||||
|
registry.Register(mockAction, mockHandler)
|
||||||
|
|
||||||
|
// Verify registration
|
||||||
|
handler, exists := registry.GetHandler(mockAction)
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, mockHandler, handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown action", func(t *testing.T) {
|
||||||
|
registry := NewHandlerRegistry()
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: common.WebSocketAction(255), // Unknown action
|
||||||
|
},
|
||||||
|
HubVerified: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Handle(ctx)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown action: 255")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("verification required", func(t *testing.T) {
|
||||||
|
registry := NewHandlerRegistry()
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: common.GetData, // Requires verification
|
||||||
|
},
|
||||||
|
HubVerified: false, // Not verified
|
||||||
|
}
|
||||||
|
|
||||||
|
err := registry.Handle(ctx)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hub not verified")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckFingerprintHandler tests the CheckFingerprint handler
|
||||||
|
func TestCheckFingerprintHandler(t *testing.T) {
|
||||||
|
handler := &CheckFingerprintHandler{}
|
||||||
|
|
||||||
|
t.Run("handle with invalid data", func(t *testing.T) {
|
||||||
|
client := &WebSocketClient{}
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Client: client,
|
||||||
|
HubVerified: false,
|
||||||
|
Request: &common.HubRequest[cbor.RawMessage]{
|
||||||
|
Action: common.CheckFingerprint,
|
||||||
|
Data: cbor.RawMessage{}, // Empty/invalid data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail to decode the fingerprint request
|
||||||
|
err := handler.Handle(ctx)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
209
agent/network.go
209
agent/network.go
@@ -12,8 +12,6 @@ import (
|
|||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
var netInterfaceDeltaTracker = deltatracker.NewDeltaTracker[string, uint64]()
|
|
||||||
|
|
||||||
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
|
// NicConfig controls inclusion/exclusion of network interfaces via the NICS env var
|
||||||
//
|
//
|
||||||
// Behavior mirrors SensorConfig's matching logic:
|
// Behavior mirrors SensorConfig's matching logic:
|
||||||
@@ -77,75 +75,17 @@ func isValidNic(nicName string, cfg *NicConfig) bool {
|
|||||||
return cfg.isBlacklist
|
return cfg.isBlacklist
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) updateNetworkStats(systemStats *system.Stats) {
|
func (a *Agent) updateNetworkStats(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||||
// network stats
|
// network stats
|
||||||
if len(a.netInterfaces) == 0 {
|
a.ensureNetInterfacesInitialized()
|
||||||
// if no network interfaces, initialize again
|
|
||||||
// this is a fix if agent started before network is online (#466)
|
|
||||||
// maybe refactor this in the future to not cache interface names at all so we
|
|
||||||
// don't miss an interface that's been added after agent started in any circumstance
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
}
|
|
||||||
|
|
||||||
if systemStats.NetworkInterfaces == nil {
|
a.ensureNetworkInterfacesMap(systemStats)
|
||||||
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
msElapsed := uint64(time.Since(a.netIoStats.Time).Milliseconds())
|
nis, msElapsed := a.loadAndTickNetBaseline(cacheTimeMs)
|
||||||
a.netIoStats.Time = time.Now()
|
totalBytesSent, totalBytesRecv := a.sumAndTrackPerNicDeltas(cacheTimeMs, msElapsed, netIO, systemStats)
|
||||||
totalBytesSent := uint64(0)
|
bytesSentPerSecond, bytesRecvPerSecond := a.computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv, nis)
|
||||||
totalBytesRecv := uint64(0)
|
a.applyNetworkTotals(cacheTimeMs, netIO, systemStats, nis, totalBytesSent, totalBytesRecv, bytesSentPerSecond, bytesRecvPerSecond)
|
||||||
netInterfaceDeltaTracker.Cycle()
|
|
||||||
// sum all bytes sent and received
|
|
||||||
for _, v := range netIO {
|
|
||||||
// skip if not in valid network interfaces list
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalBytesSent += v.BytesSent
|
|
||||||
totalBytesRecv += v.BytesRecv
|
|
||||||
|
|
||||||
// track deltas for each network interface
|
|
||||||
var upDelta, downDelta uint64
|
|
||||||
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
|
||||||
netInterfaceDeltaTracker.Set(upKey, v.BytesSent)
|
|
||||||
netInterfaceDeltaTracker.Set(downKey, v.BytesRecv)
|
|
||||||
if msElapsed > 0 {
|
|
||||||
upDelta = netInterfaceDeltaTracker.Delta(upKey) * 1000 / msElapsed
|
|
||||||
downDelta = netInterfaceDeltaTracker.Delta(downKey) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
// add interface to systemStats
|
|
||||||
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add to systemStats
|
|
||||||
var bytesSentPerSecond, bytesRecvPerSecond uint64
|
|
||||||
if msElapsed > 0 {
|
|
||||||
bytesSentPerSecond = (totalBytesSent - a.netIoStats.BytesSent) * 1000 / msElapsed
|
|
||||||
bytesRecvPerSecond = (totalBytesRecv - a.netIoStats.BytesRecv) * 1000 / msElapsed
|
|
||||||
}
|
|
||||||
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
|
||||||
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
|
||||||
// add check for issue (#150) where sent is a massive number
|
|
||||||
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
|
||||||
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
|
||||||
for _, v := range netIO {
|
|
||||||
if _, exists := a.netInterfaces[v.Name]; !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
|
||||||
}
|
|
||||||
// reset network I/O stats
|
|
||||||
a.initializeNetIoStats()
|
|
||||||
} else {
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
|
||||||
systemStats.NetworkRecv = networkRecvPs
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
|
||||||
// update netIoStats
|
|
||||||
a.netIoStats.BytesSent = totalBytesSent
|
|
||||||
a.netIoStats.BytesRecv = totalBytesRecv
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,13 +100,8 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
nicCfg = newNicConfig(nicsEnvVal)
|
nicCfg = newNicConfig(nicsEnvVal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset network I/O stats
|
// get current network I/O stats and record valid interfaces
|
||||||
a.netIoStats.BytesSent = 0
|
|
||||||
a.netIoStats.BytesRecv = 0
|
|
||||||
|
|
||||||
// get intial network I/O stats
|
|
||||||
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
if netIO, err := psutilNet.IOCounters(true); err == nil {
|
||||||
a.netIoStats.Time = time.Now()
|
|
||||||
for _, v := range netIO {
|
for _, v := range netIO {
|
||||||
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
if nicsEnvExists && !isValidNic(v.Name, nicCfg) {
|
||||||
continue
|
continue
|
||||||
@@ -175,12 +110,136 @@ func (a *Agent) initializeNetIoStats() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
slog.Info("Detected network interface", "name", v.Name, "sent", v.BytesSent, "recv", v.BytesRecv)
|
||||||
a.netIoStats.BytesSent += v.BytesSent
|
|
||||||
a.netIoStats.BytesRecv += v.BytesRecv
|
|
||||||
// store as a valid network interface
|
// store as a valid network interface
|
||||||
a.netInterfaces[v.Name] = struct{}{}
|
a.netInterfaces[v.Name] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset per-cache-time trackers and baselines so they will reinitialize on next use
|
||||||
|
a.netInterfaceDeltaTrackers = make(map[uint16]*deltatracker.DeltaTracker[string, uint64])
|
||||||
|
a.netIoStats = make(map[uint16]system.NetIoStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNetInterfacesInitialized re-initializes NICs if none are currently tracked
|
||||||
|
func (a *Agent) ensureNetInterfacesInitialized() {
|
||||||
|
if len(a.netInterfaces) == 0 {
|
||||||
|
// if no network interfaces, initialize again
|
||||||
|
// this is a fix if agent started before network is online (#466)
|
||||||
|
// maybe refactor this in the future to not cache interface names at all so we
|
||||||
|
// don't miss an interface that's been added after agent started in any circumstance
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNetworkInterfacesMap ensures systemStats.NetworkInterfaces map exists
|
||||||
|
func (a *Agent) ensureNetworkInterfacesMap(systemStats *system.Stats) {
|
||||||
|
if systemStats.NetworkInterfaces == nil {
|
||||||
|
systemStats.NetworkInterfaces = make(map[string][4]uint64, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAndTickNetBaseline returns the NetIoStats baseline and milliseconds elapsed, updating time
|
||||||
|
func (a *Agent) loadAndTickNetBaseline(cacheTimeMs uint16) (netIoStat system.NetIoStats, msElapsed uint64) {
|
||||||
|
netIoStat = a.netIoStats[cacheTimeMs]
|
||||||
|
if netIoStat.Time.IsZero() {
|
||||||
|
netIoStat.Time = time.Now()
|
||||||
|
msElapsed = 0
|
||||||
|
} else {
|
||||||
|
msElapsed = uint64(time.Since(netIoStat.Time).Milliseconds())
|
||||||
|
netIoStat.Time = time.Now()
|
||||||
|
}
|
||||||
|
return netIoStat, msElapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumAndTrackPerNicDeltas accumulates totals and records per-NIC up/down deltas into systemStats
|
||||||
|
func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, netIO []psutilNet.IOCountersStat, systemStats *system.Stats) (totalBytesSent, totalBytesRecv uint64) {
|
||||||
|
tracker := a.netInterfaceDeltaTrackers[cacheTimeMs]
|
||||||
|
if tracker == nil {
|
||||||
|
tracker = deltatracker.NewDeltaTracker[string, uint64]()
|
||||||
|
a.netInterfaceDeltaTrackers[cacheTimeMs] = tracker
|
||||||
|
}
|
||||||
|
tracker.Cycle()
|
||||||
|
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalBytesSent += v.BytesSent
|
||||||
|
totalBytesRecv += v.BytesRecv
|
||||||
|
|
||||||
|
var upDelta, downDelta uint64
|
||||||
|
upKey, downKey := fmt.Sprintf("%sup", v.Name), fmt.Sprintf("%sdown", v.Name)
|
||||||
|
tracker.Set(upKey, v.BytesSent)
|
||||||
|
tracker.Set(downKey, v.BytesRecv)
|
||||||
|
if msElapsed > 0 {
|
||||||
|
if prevVal, ok := tracker.Previous(upKey); ok {
|
||||||
|
var deltaBytes uint64
|
||||||
|
if v.BytesSent >= prevVal {
|
||||||
|
deltaBytes = v.BytesSent - prevVal
|
||||||
|
} else {
|
||||||
|
deltaBytes = v.BytesSent
|
||||||
|
}
|
||||||
|
upDelta = deltaBytes * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
if prevVal, ok := tracker.Previous(downKey); ok {
|
||||||
|
var deltaBytes uint64
|
||||||
|
if v.BytesRecv >= prevVal {
|
||||||
|
deltaBytes = v.BytesRecv - prevVal
|
||||||
|
} else {
|
||||||
|
deltaBytes = v.BytesRecv
|
||||||
|
}
|
||||||
|
downDelta = deltaBytes * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalBytesSent, totalBytesRecv
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeBytesPerSecond calculates per-second totals from elapsed time and totals
|
||||||
|
func (a *Agent) computeBytesPerSecond(msElapsed, totalBytesSent, totalBytesRecv uint64, nis system.NetIoStats) (bytesSentPerSecond, bytesRecvPerSecond uint64) {
|
||||||
|
if msElapsed > 0 {
|
||||||
|
bytesSentPerSecond = (totalBytesSent - nis.BytesSent) * 1000 / msElapsed
|
||||||
|
bytesRecvPerSecond = (totalBytesRecv - nis.BytesRecv) * 1000 / msElapsed
|
||||||
|
}
|
||||||
|
return bytesSentPerSecond, bytesRecvPerSecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyNetworkTotals validates and writes computed network stats, or resets on anomaly
|
||||||
|
func (a *Agent) applyNetworkTotals(
|
||||||
|
cacheTimeMs uint16,
|
||||||
|
netIO []psutilNet.IOCountersStat,
|
||||||
|
systemStats *system.Stats,
|
||||||
|
nis system.NetIoStats,
|
||||||
|
totalBytesSent, totalBytesRecv uint64,
|
||||||
|
bytesSentPerSecond, bytesRecvPerSecond uint64,
|
||||||
|
) {
|
||||||
|
networkSentPs := bytesToMegabytes(float64(bytesSentPerSecond))
|
||||||
|
networkRecvPs := bytesToMegabytes(float64(bytesRecvPerSecond))
|
||||||
|
if networkSentPs > 10_000 || networkRecvPs > 10_000 {
|
||||||
|
slog.Warn("Invalid net stats. Resetting.", "sent", networkSentPs, "recv", networkRecvPs)
|
||||||
|
for _, v := range netIO {
|
||||||
|
if _, exists := a.netInterfaces[v.Name]; !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Info(v.Name, "recv", v.BytesRecv, "sent", v.BytesSent)
|
||||||
|
}
|
||||||
|
a.initializeNetIoStats()
|
||||||
|
delete(a.netIoStats, cacheTimeMs)
|
||||||
|
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||||
|
systemStats.NetworkSent = 0
|
||||||
|
systemStats.NetworkRecv = 0
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
systemStats.NetworkRecv = networkRecvPs
|
||||||
|
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = bytesSentPerSecond, bytesRecvPerSecond
|
||||||
|
nis.BytesSent = totalBytesSent
|
||||||
|
nis.BytesRecv = totalBytesRecv
|
||||||
|
a.netIoStats[cacheTimeMs] = nis
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
func (a *Agent) skipNetworkInterface(v psutilNet.IOCountersStat) bool {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -257,3 +261,242 @@ func TestNewNicConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func TestEnsureNetworkInterfacesMap(t *testing.T) {
|
||||||
|
var a Agent
|
||||||
|
var stats system.Stats
|
||||||
|
|
||||||
|
// Initially nil
|
||||||
|
assert.Nil(t, stats.NetworkInterfaces)
|
||||||
|
// Ensure map is created
|
||||||
|
a.ensureNetworkInterfacesMap(&stats)
|
||||||
|
assert.NotNil(t, stats.NetworkInterfaces)
|
||||||
|
// Idempotent
|
||||||
|
a.ensureNetworkInterfacesMap(&stats)
|
||||||
|
assert.NotNil(t, stats.NetworkInterfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAndTickNetBaseline(t *testing.T) {
|
||||||
|
a := &Agent{netIoStats: make(map[uint16]system.NetIoStats)}
|
||||||
|
|
||||||
|
// First call initializes time and returns 0 elapsed
|
||||||
|
ni, elapsed := a.loadAndTickNetBaseline(100)
|
||||||
|
assert.Equal(t, uint64(0), elapsed)
|
||||||
|
assert.False(t, ni.Time.IsZero())
|
||||||
|
|
||||||
|
// Store back what loadAndTick returns to mimic updateNetworkStats behavior
|
||||||
|
a.netIoStats[100] = ni
|
||||||
|
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
|
||||||
|
// Next call should produce >= 0 elapsed and update time
|
||||||
|
ni2, elapsed2 := a.loadAndTickNetBaseline(100)
|
||||||
|
assert.True(t, elapsed2 > 0)
|
||||||
|
assert.False(t, ni2.Time.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeBytesPerSecond(t *testing.T) {
|
||||||
|
a := &Agent{}
|
||||||
|
|
||||||
|
// No elapsed -> zero rate
|
||||||
|
bytesUp, bytesDown := a.computeBytesPerSecond(0, 2000, 3000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
||||||
|
assert.Equal(t, uint64(0), bytesUp)
|
||||||
|
assert.Equal(t, uint64(0), bytesDown)
|
||||||
|
|
||||||
|
// With elapsed -> per-second calculation
|
||||||
|
bytesUp, bytesDown = a.computeBytesPerSecond(500, 6000, 11000, system.NetIoStats{BytesSent: 1000, BytesRecv: 1000})
|
||||||
|
// (6000-1000)*1000/500 = 10000; (11000-1000)*1000/500 = 20000
|
||||||
|
assert.Equal(t, uint64(10000), bytesUp)
|
||||||
|
assert.Equal(t, uint64(20000), bytesDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumAndTrackPerNicDeltas(t *testing.T) {
|
||||||
|
a := &Agent{
|
||||||
|
netInterfaces: map[string]struct{}{"eth0": {}, "wlan0": {}},
|
||||||
|
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two samples for same cache interval to verify delta behavior
|
||||||
|
cache := uint16(42)
|
||||||
|
net1 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1000, BytesRecv: 2000}}
|
||||||
|
stats1 := &system.Stats{}
|
||||||
|
a.ensureNetworkInterfacesMap(stats1)
|
||||||
|
tx1, rx1 := a.sumAndTrackPerNicDeltas(cache, 0, net1, stats1)
|
||||||
|
assert.Equal(t, uint64(1000), tx1)
|
||||||
|
assert.Equal(t, uint64(2000), rx1)
|
||||||
|
|
||||||
|
// Second cycle with elapsed, larger counters -> deltas computed inside
|
||||||
|
net2 := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4000, BytesRecv: 9000}}
|
||||||
|
stats := &system.Stats{}
|
||||||
|
a.ensureNetworkInterfacesMap(stats)
|
||||||
|
tx2, rx2 := a.sumAndTrackPerNicDeltas(cache, 1000, net2, stats)
|
||||||
|
assert.Equal(t, uint64(4000), tx2)
|
||||||
|
assert.Equal(t, uint64(9000), rx2)
|
||||||
|
// Up/Down deltas per second should be (4000-1000)/1s = 3000 and (9000-2000)/1s = 7000
|
||||||
|
ni, ok := stats.NetworkInterfaces["eth0"]
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, uint64(3000), ni[0])
|
||||||
|
assert.Equal(t, uint64(7000), ni[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumAndTrackPerNicDeltasHandlesCounterReset(t *testing.T) {
|
||||||
|
a := &Agent{
|
||||||
|
netInterfaces: map[string]struct{}{"eth0": {}},
|
||||||
|
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := uint16(77)
|
||||||
|
|
||||||
|
// First interval establishes baseline values
|
||||||
|
initial := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 4_000, BytesRecv: 6_000}}
|
||||||
|
statsInitial := &system.Stats{}
|
||||||
|
a.ensureNetworkInterfacesMap(statsInitial)
|
||||||
|
_, _ = a.sumAndTrackPerNicDeltas(cache, 0, initial, statsInitial)
|
||||||
|
|
||||||
|
// Second interval increments counters normally so previous snapshot gets populated
|
||||||
|
increment := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 9_000, BytesRecv: 11_000}}
|
||||||
|
statsIncrement := &system.Stats{}
|
||||||
|
a.ensureNetworkInterfacesMap(statsIncrement)
|
||||||
|
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, increment, statsIncrement)
|
||||||
|
|
||||||
|
niIncrement, ok := statsIncrement.NetworkInterfaces["eth0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, uint64(5_000), niIncrement[0])
|
||||||
|
assert.Equal(t, uint64(5_000), niIncrement[1])
|
||||||
|
|
||||||
|
// Third interval simulates counter reset (values drop below previous totals)
|
||||||
|
reset := []psutilNet.IOCountersStat{{Name: "eth0", BytesSent: 1_200, BytesRecv: 1_500}}
|
||||||
|
statsReset := &system.Stats{}
|
||||||
|
a.ensureNetworkInterfacesMap(statsReset)
|
||||||
|
_, _ = a.sumAndTrackPerNicDeltas(cache, 1_000, reset, statsReset)
|
||||||
|
|
||||||
|
niReset, ok := statsReset.NetworkInterfaces["eth0"]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, uint64(1_200), niReset[0], "upload delta should match new counter value after reset")
|
||||||
|
assert.Equal(t, uint64(1_500), niReset[1], "download delta should match new counter value after reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkTotals(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
bytesSentPerSecond uint64
|
||||||
|
bytesRecvPerSecond uint64
|
||||||
|
totalBytesSent uint64
|
||||||
|
totalBytesRecv uint64
|
||||||
|
expectReset bool
|
||||||
|
expectedNetworkSent float64
|
||||||
|
expectedNetworkRecv float64
|
||||||
|
expectedBandwidthSent uint64
|
||||||
|
expectedBandwidthRecv uint64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid network stats - normal values",
|
||||||
|
bytesSentPerSecond: 1000000, // 1 MB/s
|
||||||
|
bytesRecvPerSecond: 2000000, // 2 MB/s
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 0.95, // ~1 MB/s rounded to 2 decimals
|
||||||
|
expectedNetworkRecv: 1.91, // ~2 MB/s rounded to 2 decimals
|
||||||
|
expectedBandwidthSent: 1000000,
|
||||||
|
expectedBandwidthRecv: 2000000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid network stats - sent exceeds threshold",
|
||||||
|
bytesSentPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
||||||
|
bytesRecvPerSecond: 1000000, // 1 MB/s
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid network stats - recv exceeds threshold",
|
||||||
|
bytesSentPerSecond: 1000000, // 1 MB/s
|
||||||
|
bytesRecvPerSecond: 11000000000, // ~10.5 GB/s > 10 GB/s threshold
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid network stats - both exceed threshold",
|
||||||
|
bytesSentPerSecond: 12000000000, // ~11.4 GB/s
|
||||||
|
bytesRecvPerSecond: 13000000000, // ~12.4 GB/s
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid network stats - at threshold boundary",
|
||||||
|
bytesSentPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||||
|
bytesRecvPerSecond: 10485750000, // ~9999.99 MB/s (rounds to 9999.99)
|
||||||
|
totalBytesSent: 10000000,
|
||||||
|
totalBytesRecv: 20000000,
|
||||||
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 9999.99,
|
||||||
|
expectedNetworkRecv: 9999.99,
|
||||||
|
expectedBandwidthSent: 10485750000,
|
||||||
|
expectedBandwidthRecv: 10485750000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Zero values",
|
||||||
|
bytesSentPerSecond: 0,
|
||||||
|
bytesRecvPerSecond: 0,
|
||||||
|
totalBytesSent: 0,
|
||||||
|
totalBytesRecv: 0,
|
||||||
|
expectReset: false,
|
||||||
|
expectedNetworkSent: 0.0,
|
||||||
|
expectedNetworkRecv: 0.0,
|
||||||
|
expectedBandwidthSent: 0,
|
||||||
|
expectedBandwidthRecv: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Setup agent with initialized maps
|
||||||
|
a := &Agent{
|
||||||
|
netInterfaces: make(map[string]struct{}),
|
||||||
|
netIoStats: make(map[uint16]system.NetIoStats),
|
||||||
|
netInterfaceDeltaTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheTimeMs := uint16(100)
|
||||||
|
netIO := []psutilNet.IOCountersStat{
|
||||||
|
{Name: "eth0", BytesSent: 1000, BytesRecv: 2000},
|
||||||
|
}
|
||||||
|
systemStats := &system.Stats{}
|
||||||
|
nis := system.NetIoStats{}
|
||||||
|
|
||||||
|
a.applyNetworkTotals(
|
||||||
|
cacheTimeMs,
|
||||||
|
netIO,
|
||||||
|
systemStats,
|
||||||
|
nis,
|
||||||
|
tt.totalBytesSent,
|
||||||
|
tt.totalBytesRecv,
|
||||||
|
tt.bytesSentPerSecond,
|
||||||
|
tt.bytesRecvPerSecond,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectReset {
|
||||||
|
// Should have reset network tracking state - maps cleared and stats zeroed
|
||||||
|
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
||||||
|
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on reset")
|
||||||
|
assert.Zero(t, systemStats.NetworkSent)
|
||||||
|
assert.Zero(t, systemStats.NetworkRecv)
|
||||||
|
assert.Zero(t, systemStats.Bandwidth[0])
|
||||||
|
assert.Zero(t, systemStats.Bandwidth[1])
|
||||||
|
} else {
|
||||||
|
// Should have applied stats
|
||||||
|
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
||||||
|
assert.Equal(t, tt.expectedNetworkRecv, systemStats.NetworkRecv)
|
||||||
|
assert.Equal(t, tt.expectedBandwidthSent, systemStats.Bandwidth[0])
|
||||||
|
assert.Equal(t, tt.expectedBandwidthRecv, systemStats.Bandwidth[1])
|
||||||
|
|
||||||
|
// Should have updated NetIoStats
|
||||||
|
updatedNis := a.netIoStats[cacheTimeMs]
|
||||||
|
assert.Equal(t, tt.totalBytesSent, updatedNis.BytesSent)
|
||||||
|
assert.Equal(t, tt.totalBytesRecv, updatedNis.BytesRecv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
@@ -127,15 +128,79 @@ func (a *Agent) handleSession(s ssh.Session) {
|
|||||||
|
|
||||||
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
hubVersion := a.getHubVersion(sessionID, sessionCtx)
|
||||||
|
|
||||||
stats := a.gatherStats(sessionID)
|
// Legacy one-shot behavior for older hubs
|
||||||
|
if hubVersion.LT(beszel.MinVersionAgentResponse) {
|
||||||
err := a.writeToSession(s, stats, hubVersion)
|
if err := a.handleLegacyStats(s, hubVersion); err != nil {
|
||||||
if err != nil {
|
slog.Error("Error encoding stats", "err", err)
|
||||||
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
s.Exit(1)
|
||||||
s.Exit(1)
|
return
|
||||||
} else {
|
}
|
||||||
s.Exit(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var req common.HubRequest[cbor.RawMessage]
|
||||||
|
if err := cbor.NewDecoder(s).Decode(&req); err != nil {
|
||||||
|
// Fallback to legacy one-shot if the first decode fails
|
||||||
|
if err2 := a.handleLegacyStats(s, hubVersion); err2 != nil {
|
||||||
|
slog.Error("Error encoding stats (fallback)", "err", err2)
|
||||||
|
s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Exit(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.handleSSHRequest(s, &req); err != nil {
|
||||||
|
slog.Error("SSH request handling failed", "err", err)
|
||||||
|
s.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSSHRequest builds a handler context and dispatches to the shared registry
|
||||||
|
func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMessage]) error {
|
||||||
|
// SSH does not support fingerprint auth action
|
||||||
|
if req.Action == common.CheckFingerprint {
|
||||||
|
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: "unsupported action"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// responder that writes AgentResponse to stdout
|
||||||
|
sshResponder := func(data any, requestID *uint32) error {
|
||||||
|
response := common.AgentResponse{Id: requestID}
|
||||||
|
switch v := data.(type) {
|
||||||
|
case *system.CombinedData:
|
||||||
|
response.SystemData = v
|
||||||
|
case string:
|
||||||
|
response.String = &v
|
||||||
|
case map[string]smart.SmartData:
|
||||||
|
response.SmartData = v
|
||||||
|
default:
|
||||||
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
|
}
|
||||||
|
return cbor.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := &HandlerContext{
|
||||||
|
Client: nil,
|
||||||
|
Agent: a,
|
||||||
|
Request: req,
|
||||||
|
RequestID: nil,
|
||||||
|
HubVerified: true,
|
||||||
|
SendResponse: sshResponder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if handler, ok := a.handlerRegistry.GetHandler(req.Action); ok {
|
||||||
|
if err := handler.Handle(ctx); err != nil {
|
||||||
|
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: err.Error()})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cbor.NewEncoder(w).Encode(common.AgentResponse{Error: fmt.Sprintf("unknown action: %d", req.Action)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
|
stats := a.gatherStats(60_000)
|
||||||
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeToSession encodes and writes system statistics to the session.
|
// writeToSession encodes and writes system statistics to the session.
|
||||||
|
|||||||
402
agent/smart.go
Normal file
402
agent/smart.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SmartManager manages data collection for SMART devices
|
||||||
|
type SmartManager struct {
|
||||||
|
sync.Mutex
|
||||||
|
SmartDataMap map[string]*smart.SmartData
|
||||||
|
SmartDevices []*DeviceInfo
|
||||||
|
refreshMutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanOutput struct {
|
||||||
|
Devices []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||||
|
|
||||||
|
// Refresh updates SMART data for all known devices on demand.
|
||||||
|
func (sm *SmartManager) Refresh() error {
|
||||||
|
sm.refreshMutex.Lock()
|
||||||
|
defer sm.refreshMutex.Unlock()
|
||||||
|
|
||||||
|
scanErr := sm.ScanDevices()
|
||||||
|
if scanErr != nil {
|
||||||
|
slog.Debug("smartctl scan failed", "err", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
devices := sm.devicesSnapshot()
|
||||||
|
var collectErr error
|
||||||
|
for _, deviceInfo := range devices {
|
||||||
|
if deviceInfo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := sm.CollectSmart(deviceInfo); err != nil {
|
||||||
|
slog.Debug("smartctl collect failed, skipping", "device", deviceInfo.Name, "err", err)
|
||||||
|
collectErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sm.resolveRefreshError(scanErr, collectErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicesSnapshot returns a copy of the current device slice to avoid iterating
|
||||||
|
// while holding the primary mutex for longer than necessary.
|
||||||
|
func (sm *SmartManager) devicesSnapshot() []*DeviceInfo {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
devices := make([]*DeviceInfo, len(sm.SmartDevices))
|
||||||
|
copy(devices, sm.SmartDevices)
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSmartData reports whether any SMART data has been collected.
|
||||||
|
// func (sm *SmartManager) hasSmartData() bool {
|
||||||
|
// sm.Lock()
|
||||||
|
// defer sm.Unlock()
|
||||||
|
|
||||||
|
// return len(sm.SmartDataMap) > 0
|
||||||
|
// }
|
||||||
|
|
||||||
|
// resolveRefreshError determines the proper error to return after a refresh.
|
||||||
|
func (sm *SmartManager) resolveRefreshError(scanErr, collectErr error) error {
|
||||||
|
sm.Lock()
|
||||||
|
noDevices := len(sm.SmartDevices) == 0
|
||||||
|
noData := len(sm.SmartDataMap) == 0
|
||||||
|
sm.Unlock()
|
||||||
|
|
||||||
|
if noDevices {
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !noData {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if collectErr != nil {
|
||||||
|
return collectErr
|
||||||
|
}
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentData returns the current SMART data
|
||||||
|
func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
result := make(map[string]smart.SmartData, len(sm.SmartDataMap))
|
||||||
|
for key, value := range sm.SmartDataMap {
|
||||||
|
if value != nil {
|
||||||
|
result[key] = *value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanDevices scans for SMART devices
|
||||||
|
// Scan devices using `smartctl --scan -j`
|
||||||
|
// If scan fails, return error
|
||||||
|
// If scan succeeds, parse the output and update the SmartDevices slice
|
||||||
|
func (sm *SmartManager) ScanDevices() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidData := sm.parseScan(output)
|
||||||
|
if !hasValidData {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectSmart collects SMART data for a device
|
||||||
|
// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX`
|
||||||
|
// Always attempts to parse output even if command fails, as some data may still be available
|
||||||
|
// If collect fails, return error
|
||||||
|
// If collect succeeds, parse the output and update the SmartDataMap
|
||||||
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
|
// for initial data collection when no cached data exists
|
||||||
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
// Check if we have any existing data for this device
|
||||||
|
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Try with -n standby first if we have existing data
|
||||||
|
cmd := exec.CommandContext(ctx, "smartctl", "-aj", "-n", "standby", deviceInfo.Name)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
// Check if device is in standby (exit status 2)
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
||||||
|
if hasExistingData {
|
||||||
|
// Device is in standby and we have cached data, keep using cache
|
||||||
|
slog.Debug("device in standby mode, using cached data", "device", deviceInfo.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// No cached data, need to collect initial data by bypassing standby
|
||||||
|
slog.Debug("device in standby but no cached data, collecting initial data", "device", deviceInfo.Name)
|
||||||
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel2()
|
||||||
|
cmd = exec.CommandContext(ctx2, "smartctl", "-aj", deviceInfo.Name)
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidData := false
|
||||||
|
|
||||||
|
switch deviceInfo.Type {
|
||||||
|
case "scsi", "sat", "ata":
|
||||||
|
// parse SATA/SCSI/ATA devices
|
||||||
|
hasValidData, _ = sm.parseSmartForSata(output)
|
||||||
|
case "nvme":
|
||||||
|
// parse nvme devices
|
||||||
|
hasValidData, _ = sm.parseSmartForNvme(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasValidData {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasDataForDevice checks if we have cached SMART data for a specific device
|
||||||
|
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
// Check if any cached data has this device name
|
||||||
|
for _, data := range sm.SmartDataMap {
|
||||||
|
if data != nil && data.DiskName == deviceName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice
|
||||||
|
func (sm *SmartManager) parseScan(output []byte) bool {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
sm.SmartDevices = make([]*DeviceInfo, 0)
|
||||||
|
scan := &scanOutput{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, scan); err != nil {
|
||||||
|
slog.Debug("Failed to parse smartctl scan JSON", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scan.Devices) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedDeviceNameMap := make(map[string]bool, len(scan.Devices))
|
||||||
|
|
||||||
|
for _, device := range scan.Devices {
|
||||||
|
deviceInfo := &DeviceInfo{
|
||||||
|
Name: device.Name,
|
||||||
|
Type: device.Type,
|
||||||
|
InfoName: device.InfoName,
|
||||||
|
Protocol: device.Protocol,
|
||||||
|
}
|
||||||
|
sm.SmartDevices = append(sm.SmartDevices, deviceInfo)
|
||||||
|
scannedDeviceNameMap[device.Name] = true
|
||||||
|
}
|
||||||
|
// remove devices that are not in the scan
|
||||||
|
for key := range sm.SmartDataMap {
|
||||||
|
if _, ok := scannedDeviceNameMap[key]; !ok {
|
||||||
|
delete(sm.SmartDataMap, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
||||||
|
// Returns hasValidData and exitStatus
|
||||||
|
func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
||||||
|
var data smart.SmartInfoForSata
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.SerialNumber == "" {
|
||||||
|
slog.Debug("device has no serial number, skipping", "device", data.Device.Name)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
// get device name (e.g. /dev/sda)
|
||||||
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
|
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||||
|
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update SmartData
|
||||||
|
smartData := sm.SmartDataMap[keyName]
|
||||||
|
// smartData.ModelFamily = data.ModelFamily
|
||||||
|
smartData.ModelName = data.ModelName
|
||||||
|
smartData.SerialNumber = data.SerialNumber
|
||||||
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
smartData.Temperature = data.Temperature.Current
|
||||||
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
|
smartData.DiskName = data.Device.Name
|
||||||
|
smartData.DiskType = data.Device.Type
|
||||||
|
|
||||||
|
// update SmartAttributes
|
||||||
|
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||||
|
for _, attr := range data.AtaSmartAttributes.Table {
|
||||||
|
smartAttr := &smart.SmartAttribute{
|
||||||
|
ID: attr.ID,
|
||||||
|
Name: attr.Name,
|
||||||
|
Value: attr.Value,
|
||||||
|
Worst: attr.Worst,
|
||||||
|
Threshold: attr.Thresh,
|
||||||
|
RawValue: attr.Raw.Value,
|
||||||
|
RawString: attr.Raw.String,
|
||||||
|
WhenFailed: attr.WhenFailed,
|
||||||
|
}
|
||||||
|
smartData.Attributes = append(smartData.Attributes, smartAttr)
|
||||||
|
}
|
||||||
|
sm.SmartDataMap[keyName] = smartData
|
||||||
|
|
||||||
|
return true, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSmartStatus(temperature uint8, passed bool) string {
|
||||||
|
if passed {
|
||||||
|
return "PASSED"
|
||||||
|
} else if temperature > 0 {
|
||||||
|
return "FAILED"
|
||||||
|
} else {
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
|
// Returns hasValidData and exitStatus
|
||||||
|
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||||
|
data := &smart.SmartInfoForNvme{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.SerialNumber == "" {
|
||||||
|
slog.Debug("device has no serial number, skipping", "device", data.Device.Name)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
// get device name (e.g. /dev/nvme0)
|
||||||
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
|
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||||
|
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update SmartData
|
||||||
|
smartData := sm.SmartDataMap[keyName]
|
||||||
|
smartData.ModelName = data.ModelName
|
||||||
|
smartData.SerialNumber = data.SerialNumber
|
||||||
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||||
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
|
smartData.DiskName = data.Device.Name
|
||||||
|
smartData.DiskType = data.Device.Type
|
||||||
|
|
||||||
|
// nvme attributes does not follow the same format as ata attributes,
|
||||||
|
// so we manually map each field to SmartAttributes
|
||||||
|
log := data.NVMeSmartHealthInformationLog
|
||||||
|
smartData.Attributes = []*smart.SmartAttribute{
|
||||||
|
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
|
||||||
|
{Name: "Temperature", RawValue: uint64(log.Temperature)},
|
||||||
|
{Name: "AvailableSpare", RawValue: uint64(log.AvailableSpare)},
|
||||||
|
{Name: "AvailableSpareThreshold", RawValue: uint64(log.AvailableSpareThreshold)},
|
||||||
|
{Name: "PercentageUsed", RawValue: uint64(log.PercentageUsed)},
|
||||||
|
{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
|
||||||
|
{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
|
||||||
|
{Name: "HostReads", RawValue: uint64(log.HostReads)},
|
||||||
|
{Name: "HostWrites", RawValue: uint64(log.HostWrites)},
|
||||||
|
{Name: "ControllerBusyTime", RawValue: uint64(log.ControllerBusyTime)},
|
||||||
|
{Name: "PowerCycles", RawValue: uint64(log.PowerCycles)},
|
||||||
|
{Name: "PowerOnHours", RawValue: uint64(log.PowerOnHours)},
|
||||||
|
{Name: "UnsafeShutdowns", RawValue: uint64(log.UnsafeShutdowns)},
|
||||||
|
{Name: "MediaErrors", RawValue: uint64(log.MediaErrors)},
|
||||||
|
{Name: "NumErrLogEntries", RawValue: uint64(log.NumErrLogEntries)},
|
||||||
|
{Name: "WarningTempTime", RawValue: uint64(log.WarningTempTime)},
|
||||||
|
{Name: "CriticalCompTime", RawValue: uint64(log.CriticalCompTime)},
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.SmartDataMap[keyName] = smartData
|
||||||
|
|
||||||
|
return true, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
|
func (sm *SmartManager) detectSmartctl() error {
|
||||||
|
if _, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("smartctl not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSmartManager creates and initializes a new SmartManager
|
||||||
|
func NewSmartManager() (*SmartManager, error) {
|
||||||
|
sm := &SmartManager{
|
||||||
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
|
}
|
||||||
|
if err := sm.detectSmartctl(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sm, nil
|
||||||
|
}
|
||||||
@@ -14,12 +14,18 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/load"
|
"github.com/shirou/gopsutil/v4/load"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||||
|
type prevDisk struct {
|
||||||
|
readBytes uint64
|
||||||
|
writeBytes uint64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) initializeSystemInfo() {
|
func (a *Agent) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
@@ -68,20 +74,21 @@ func (a *Agent) initializeSystemInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats() system.Stats {
|
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if battery.HasReadableBattery() {
|
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
||||||
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
systemStats.Battery[0] = batteryPercent
|
||||||
|
systemStats.Battery[1] = batteryState
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu percent
|
||||||
cpuPct, err := cpu.Percent(0, false)
|
cpuPercent, err := getCpuPercent(cacheTimeMs)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
systemStats.Cpu = twoDecimals(cpuPercent)
|
||||||
|
} else {
|
||||||
slog.Error("Error getting cpu percent", "err", err)
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
} else if len(cpuPct) > 0 {
|
|
||||||
systemStats.Cpu = twoDecimals(cpuPct[0])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// load average
|
// load average
|
||||||
@@ -102,6 +109,9 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
// cache + buffers value for default mem calculation
|
// cache + buffers value for default mem calculation
|
||||||
// note: gopsutil automatically adds SReclaimable to v.Cached
|
// note: gopsutil automatically adds SReclaimable to v.Cached
|
||||||
cacheBuff := v.Cached + v.Buffers - v.Shared
|
cacheBuff := v.Cached + v.Buffers - v.Shared
|
||||||
|
if cacheBuff <= 0 {
|
||||||
|
cacheBuff = max(v.Total-v.Free-v.Used, 0)
|
||||||
|
}
|
||||||
// htop memory calculation overrides (likely outdated as of mid 2025)
|
// htop memory calculation overrides (likely outdated as of mid 2025)
|
||||||
if a.memCalc == "htop" {
|
if a.memCalc == "htop" {
|
||||||
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
// cacheBuff = v.Cached + v.Buffers - v.Shared
|
||||||
@@ -128,56 +138,13 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
a.updateDiskUsage(&systemStats)
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskTotal = bytesToGigabytes(d.Total)
|
|
||||||
systemStats.DiskUsed = bytesToGigabytes(d.Used)
|
|
||||||
systemStats.DiskPct = twoDecimals(d.UsedPercent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// reset stats if error (likely unmounted)
|
|
||||||
slog.Error("Error getting disk stats", "name", stats.Mountpoint, "err", err)
|
|
||||||
stats.DiskTotal = 0
|
|
||||||
stats.DiskUsed = 0
|
|
||||||
stats.TotalRead = 0
|
|
||||||
stats.TotalWrite = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// disk i/o
|
// disk i/o (cache-aware per interval)
|
||||||
if ioCounters, err := disk.IOCounters(a.fsNames...); err == nil {
|
a.updateDiskIo(cacheTimeMs, &systemStats)
|
||||||
for _, d := range ioCounters {
|
|
||||||
stats := a.fsStats[d.Name]
|
|
||||||
if stats == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
secondsElapsed := time.Since(stats.Time).Seconds()
|
|
||||||
readPerSecond := bytesToMegabytes(float64(d.ReadBytes-stats.TotalRead) / secondsElapsed)
|
|
||||||
writePerSecond := bytesToMegabytes(float64(d.WriteBytes-stats.TotalWrite) / secondsElapsed)
|
|
||||||
// check for invalid values and reset stats if so
|
|
||||||
if readPerSecond < 0 || writePerSecond < 0 || readPerSecond > 50_000 || writePerSecond > 50_000 {
|
|
||||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readPerSecond, "write", writePerSecond)
|
|
||||||
a.initializeDiskIoStats(ioCounters)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
stats.Time = time.Now()
|
|
||||||
stats.DiskReadPs = readPerSecond
|
|
||||||
stats.DiskWritePs = writePerSecond
|
|
||||||
stats.TotalRead = d.ReadBytes
|
|
||||||
stats.TotalWrite = d.WriteBytes
|
|
||||||
// if root filesystem, update system stats
|
|
||||||
if stats.Root {
|
|
||||||
systemStats.DiskReadPs = stats.DiskReadPs
|
|
||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// network stats
|
// network stats (per cache interval)
|
||||||
a.updateNetworkStats(&systemStats)
|
a.updateNetworkStats(cacheTimeMs, &systemStats)
|
||||||
|
|
||||||
// temperatures
|
// temperatures
|
||||||
// TODO: maybe refactor to methods on systemStats
|
// TODO: maybe refactor to methods on systemStats
|
||||||
@@ -188,7 +155,7 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
// reset high gpu percent
|
// reset high gpu percent
|
||||||
a.systemInfo.GpuPct = 0
|
a.systemInfo.GpuPct = 0
|
||||||
// get current GPU data
|
// get current GPU data
|
||||||
if gpuData := a.gpuManager.GetCurrentData(); len(gpuData) > 0 {
|
if gpuData := a.gpuManager.GetCurrentData(cacheTimeMs); len(gpuData) > 0 {
|
||||||
systemStats.GPUData = gpuData
|
systemStats.GPUData = gpuData
|
||||||
|
|
||||||
// add temperatures
|
// add temperatures
|
||||||
|
|||||||
24
agent/test-data/container.json
Normal file
24
agent/test-data/container.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"cpu_stats": {
|
||||||
|
"cpu_usage": {
|
||||||
|
"total_usage": 312055276000
|
||||||
|
},
|
||||||
|
"system_cpu_usage": 1366399830000000
|
||||||
|
},
|
||||||
|
"memory_stats": {
|
||||||
|
"usage": 507400192,
|
||||||
|
"stats": {
|
||||||
|
"inactive_file": 165130240
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"eth0": {
|
||||||
|
"tx_bytes": 20376558,
|
||||||
|
"rx_bytes": 537029455
|
||||||
|
},
|
||||||
|
"eth1": {
|
||||||
|
"tx_bytes": 2003766,
|
||||||
|
"rx_bytes": 6241
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
agent/test-data/container2.json
Normal file
24
agent/test-data/container2.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"cpu_stats": {
|
||||||
|
"cpu_usage": {
|
||||||
|
"total_usage": 314891801000
|
||||||
|
},
|
||||||
|
"system_cpu_usage": 1368474900000000
|
||||||
|
},
|
||||||
|
"memory_stats": {
|
||||||
|
"usage": 507400192,
|
||||||
|
"stats": {
|
||||||
|
"inactive_file": 165130240
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"networks": {
|
||||||
|
"eth0": {
|
||||||
|
"tx_bytes": 20376558,
|
||||||
|
"rx_bytes": 537029455
|
||||||
|
},
|
||||||
|
"eth1": {
|
||||||
|
"tx_bytes": 2003766,
|
||||||
|
"rx_bytes": 6241
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,21 +30,22 @@ func (s *systemdRestarter) Restart() error {
|
|||||||
type openRCRestarter struct{ cmd string }
|
type openRCRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (o *openRCRestarter) Restart() error {
|
func (o *openRCRestarter) Restart() error {
|
||||||
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
if err := exec.Command(o.cmd, "beszel-agent", "status").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
||||||
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(o.cmd, "beszel-agent", "restart").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type openWRTRestarter struct{ cmd string }
|
type openWRTRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (w *openWRTRestarter) Restart() error {
|
func (w *openWRTRestarter) Restart() error {
|
||||||
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
|
// https://openwrt.org/docs/guide-user/base-system/managing_services?s[]=service
|
||||||
|
if err := exec.Command("/etc/init.d/beszel-agent", "running").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
||||||
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
return exec.Command("/etc/init.d/beszel-agent", "restart").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type freeBSDRestarter struct{ cmd string }
|
type freeBSDRestarter struct{ cmd string }
|
||||||
@@ -64,11 +65,13 @@ func detectRestarter() restarter {
|
|||||||
if path, err := exec.LookPath("rc-service"); err == nil {
|
if path, err := exec.LookPath("rc-service"); err == nil {
|
||||||
return &openRCRestarter{cmd: path}
|
return &openRCRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
|
if path, err := exec.LookPath("procd"); err == nil {
|
||||||
|
return &openWRTRestarter{cmd: path}
|
||||||
|
}
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
if runtime.GOOS == "freebsd" {
|
if runtime.GOOS == "freebsd" {
|
||||||
return &freeBSDRestarter{cmd: path}
|
return &freeBSDRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
return &openWRTRestarter{cmd: path}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.12.11"
|
Version = "0.15.0"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MinVersionCbor is the minimum supported version for CBOR compatibility.
|
// MinVersionCbor is the minimum supported version for CBOR compatibility.
|
||||||
var MinVersionCbor = semver.MustParse("0.12.0")
|
var MinVersionCbor = semver.MustParse("0.12.0")
|
||||||
|
|
||||||
|
// MinVersionAgentResponse is the minimum supported version for AgentResponse compatibility.
|
||||||
|
var MinVersionAgentResponse = semver.MustParse("0.13.0")
|
||||||
|
|||||||
37
go.mod
37
go.mod
@@ -1,9 +1,6 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.3
|
||||||
|
|
||||||
// lock shoutrrr to specific version to allow review before updating
|
|
||||||
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.9.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
@@ -12,16 +9,16 @@ require (
|
|||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.9.1
|
github.com/nicholas-fedor/shoutrrr v0.11.0
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.30.0
|
github.com/pocketbase/pocketbase v0.31.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8
|
github.com/shirou/gopsutil/v4 v4.25.9
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.43.0
|
||||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,11 +39,11 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
@@ -54,16 +51,16 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.31.0 // indirect
|
golang.org/x/image v0.32.0 // indirect
|
||||||
golang.org/x/net v0.44.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/oauth2 v0.31.0 // indirect
|
golang.org/x/oauth2 v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/term v0.36.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.38.2 // indirect
|
modernc.org/sqlite v1.39.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
98
go.sum
98
go.sum
@@ -54,8 +54,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
|
|||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
||||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
@@ -63,26 +63,26 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs=
|
github.com/nicholas-fedor/shoutrrr v0.11.0 h1:hAMv2uM8OfFXkMHVP977elkP3Wgw5/YpVX5GxXQwiWA=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk=
|
github.com/nicholas-fedor/shoutrrr v0.11.0/go.mod h1:0kRF9ral22xUn/0BlxfhLQUeJDTySCPsuNvaclyagb4=
|
||||||
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
|
||||||
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -90,8 +90,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.30.0 h1:7v9O3hBYyHyptnnFjdP8tEJIuyHEfjhG6PC4gjf5eoE=
|
github.com/pocketbase/pocketbase v0.31.0 h1:JaOtSDytdA+a0r4689Mrjda4rmq+BaHgEJkPeOIydms=
|
||||||
github.com/pocketbase/pocketbase v0.30.0/go.mod h1:gZIwampw4VqMcEdGHwBZgSa54xWIDgVJb4uINUMXLmA=
|
github.com/pocketbase/pocketbase v0.31.0/go.mod h1:p4a83n+DlBcTvvqhC7QDy0KDmQ2la2c6dgxdIBWwKiE=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -99,8 +99,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
@@ -125,20 +125,20 @@ go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwE
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -146,43 +146,41 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.4 h1:jPhG8oNjtTYuP2FA4YefTJ/wioNUGALmGuEWt7SUR6s=
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.26.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/fileutil v1.3.28 h1:Vp156KUA2nPu9F1NEv036x9UGOjg2qsi5QlWTjZmtMk=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.28/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0=
|
|
||||||
modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -191,8 +189,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
type WebSocketAction = uint8
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
)
|
||||||
|
|
||||||
// Not implemented yet
|
type WebSocketAction = uint8
|
||||||
// type AgentError = uint8
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Request system data from agent
|
// Request system data from agent
|
||||||
GetData WebSocketAction = iota
|
GetData WebSocketAction = iota
|
||||||
// Check the fingerprint of the agent
|
// Check the fingerprint of the agent
|
||||||
CheckFingerprint
|
CheckFingerprint
|
||||||
|
// Request container logs from agent
|
||||||
|
GetContainerLogs
|
||||||
|
// Request container info from agent
|
||||||
|
GetContainerInfo
|
||||||
|
// Request SMART data from agent
|
||||||
|
GetSmartData
|
||||||
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
// HubRequest defines the structure for requests sent from hub to agent.
|
// HubRequest defines the structure for requests sent from hub to agent.
|
||||||
type HubRequest[T any] struct {
|
type HubRequest[T any] struct {
|
||||||
Action WebSocketAction `cbor:"0,keyasint"`
|
Action WebSocketAction `cbor:"0,keyasint"`
|
||||||
Data T `cbor:"1,keyasint,omitempty,omitzero"`
|
Data T `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
// Error AgentError `cbor:"error,omitempty,omitzero"`
|
Id *uint32 `cbor:"2,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
|
type AgentResponse struct {
|
||||||
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
|
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
|
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
|
||||||
|
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintRequest struct {
|
type FingerprintRequest struct {
|
||||||
@@ -27,6 +48,20 @@ type FingerprintRequest struct {
|
|||||||
type FingerprintResponse struct {
|
type FingerprintResponse struct {
|
||||||
Fingerprint string `cbor:"0,keyasint"`
|
Fingerprint string `cbor:"0,keyasint"`
|
||||||
// Optional system info for universal token system creation
|
// Optional system info for universal token system creation
|
||||||
Hostname string `cbor:"1,keyasint,omitempty,omitzero"`
|
Hostname string `cbor:"1,keyasint,omitzero"`
|
||||||
Port string `cbor:"2,keyasint,omitempty,omitzero"`
|
Port string `cbor:"2,keyasint,omitzero"`
|
||||||
|
Name string `cbor:"3,keyasint,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataRequestOptions struct {
|
||||||
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
|
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerLogsRequest struct {
|
||||||
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerInfoRequest struct {
|
||||||
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,7 @@ COPY --from=builder /agent /agent
|
|||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
|
# Ensure data persistence across container recreations
|
||||||
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
28
internal/dockerfile_agent_alpine
Normal file
28
internal/dockerfile_agent_alpine
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ../go.mod ../go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Build
|
||||||
|
ARG TARGETOS TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
|
# --------------------------
|
||||||
|
# Final image: default scratch-based agent
|
||||||
|
# --------------------------
|
||||||
|
FROM alpine:latest
|
||||||
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
RUN apk add --no-cache smartmontools
|
||||||
|
|
||||||
|
# Ensure data persistence across container recreations
|
||||||
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -20,6 +20,9 @@ FROM alpine:edge
|
|||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools smartmontools
|
||||||
|
|
||||||
|
# Ensure data persistence across container recreations
|
||||||
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -24,4 +24,9 @@ COPY --from=builder /agent /agent
|
|||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y smartmontools && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Ensure data persistence across container recreations
|
||||||
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ FROM scratch
|
|||||||
COPY --from=builder /beszel /
|
COPY --from=builder /beszel /
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
|
|
||||||
|
# Ensure data persistence across container recreations
|
||||||
|
VOLUME ["/beszel_data"]
|
||||||
|
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|
||||||
ENTRYPOINT [ "/beszel" ]
|
ENTRYPOINT [ "/beszel" ]
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ type ApiInfo struct {
|
|||||||
IdShort string
|
IdShort string
|
||||||
Names []string
|
Names []string
|
||||||
Status string
|
Status string
|
||||||
// Image string
|
State string
|
||||||
|
Image string
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
// Created int64
|
// Created int64
|
||||||
@@ -16,7 +17,6 @@ type ApiInfo struct {
|
|||||||
// SizeRw int64 `json:",omitempty"`
|
// SizeRw int64 `json:",omitempty"`
|
||||||
// SizeRootFs int64 `json:",omitempty"`
|
// SizeRootFs int64 `json:",omitempty"`
|
||||||
// Labels map[string]string
|
// Labels map[string]string
|
||||||
// State string
|
|
||||||
// HostConfig struct {
|
// HostConfig struct {
|
||||||
// NetworkMode string `json:",omitempty"`
|
// NetworkMode string `json:",omitempty"`
|
||||||
// Annotations map[string]string `json:",omitempty"`
|
// Annotations map[string]string `json:",omitempty"`
|
||||||
@@ -103,6 +103,22 @@ type prevNetStats struct {
|
|||||||
Recv uint64
|
Recv uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DockerHealth = uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
DockerHealthNone DockerHealth = iota
|
||||||
|
DockerHealthStarting
|
||||||
|
DockerHealthHealthy
|
||||||
|
DockerHealthUnhealthy
|
||||||
|
)
|
||||||
|
|
||||||
|
var DockerHealthStrings = map[string]DockerHealth{
|
||||||
|
"none": DockerHealthNone,
|
||||||
|
"starting": DockerHealthStarting,
|
||||||
|
"healthy": DockerHealthHealthy,
|
||||||
|
"unhealthy": DockerHealthUnhealthy,
|
||||||
|
}
|
||||||
|
|
||||||
// Docker container stats
|
// Docker container stats
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
@@ -110,6 +126,11 @@ type Stats struct {
|
|||||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||||
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
NetworkSent float64 `json:"ns" cbor:"3,keyasint"`
|
||||||
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
NetworkRecv float64 `json:"nr" cbor:"4,keyasint"`
|
||||||
|
|
||||||
|
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||||
|
Status string `json:"-" cbor:"6,keyasint"`
|
||||||
|
Id string `json:"-" cbor:"7,keyasint"`
|
||||||
|
Image string `json:"-" cbor:"8,keyasint"`
|
||||||
// PrevCpu [2]uint64 `json:"-"`
|
// PrevCpu [2]uint64 `json:"-"`
|
||||||
CpuSystem uint64 `json:"-"`
|
CpuSystem uint64 `json:"-"`
|
||||||
CpuContainer uint64 `json:"-"`
|
CpuContainer uint64 `json:"-"`
|
||||||
|
|||||||
362
internal/entities/smart/smart.go
Normal file
362
internal/entities/smart/smart.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package smart
|
||||||
|
|
||||||
|
// Common types
|
||||||
|
type VersionInfo [2]int
|
||||||
|
|
||||||
|
type SmartctlInfo struct {
|
||||||
|
Version VersionInfo `json:"version"`
|
||||||
|
SvnRevision string `json:"svn_revision"`
|
||||||
|
PlatformInfo string `json:"platform_info"`
|
||||||
|
BuildInfo string `json:"build_info"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
ExitStatus int `json:"exit_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserCapacity struct {
|
||||||
|
Blocks uint64 `json:"blocks"`
|
||||||
|
Bytes uint64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type LocalTime struct {
|
||||||
|
// TimeT int64 `json:"time_t"`
|
||||||
|
// Asctime string `json:"asctime"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type WwnInfo struct {
|
||||||
|
// Naa int `json:"naa"`
|
||||||
|
// Oui int `json:"oui"`
|
||||||
|
// ID int `json:"id"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type FormFactorInfo struct {
|
||||||
|
// AtaValue int `json:"ata_value"`
|
||||||
|
// Name string `json:"name"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type TrimInfo struct {
|
||||||
|
// Supported bool `json:"supported"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type AtaVersionInfo struct {
|
||||||
|
// String string `json:"string"`
|
||||||
|
// MajorValue int `json:"major_value"`
|
||||||
|
// MinorValue int `json:"minor_value"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type VersionStringInfo struct {
|
||||||
|
// String string `json:"string"`
|
||||||
|
// Value int `json:"value"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type SpeedInfo struct {
|
||||||
|
// SataValue int `json:"sata_value"`
|
||||||
|
// String string `json:"string"`
|
||||||
|
// UnitsPerSecond int `json:"units_per_second"`
|
||||||
|
// BitsPerUnit int `json:"bits_per_unit"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type InterfaceSpeedInfo struct {
|
||||||
|
// Max SpeedInfo `json:"max"`
|
||||||
|
// Current SpeedInfo `json:"current"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type SmartStatusInfo struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusInfo struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PollingMinutes struct {
|
||||||
|
Short int `json:"short"`
|
||||||
|
Extended int `json:"extended"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CapabilitiesInfo struct {
|
||||||
|
Values []int `json:"values"`
|
||||||
|
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
|
||||||
|
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
|
||||||
|
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
|
||||||
|
SelfTestsSupported bool `json:"self_tests_supported"`
|
||||||
|
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
|
||||||
|
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
|
||||||
|
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
|
||||||
|
ErrorLoggingSupported bool `json:"error_logging_supported"`
|
||||||
|
GpLoggingSupported bool `json:"gp_logging_supported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type AtaSmartData struct {
|
||||||
|
// OfflineDataCollection OfflineDataCollectionInfo `json:"offline_data_collection"`
|
||||||
|
// SelfTest SelfTestInfo `json:"self_test"`
|
||||||
|
// Capabilities CapabilitiesInfo `json:"capabilities"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type OfflineDataCollectionInfo struct {
|
||||||
|
// Status StatusInfo `json:"status"`
|
||||||
|
// CompletionSeconds int `json:"completion_seconds"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type SelfTestInfo struct {
|
||||||
|
// Status StatusInfo `json:"status"`
|
||||||
|
// PollingMinutes PollingMinutes `json:"polling_minutes"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type AtaSctCapabilities struct {
|
||||||
|
// Value int `json:"value"`
|
||||||
|
// ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
|
||||||
|
// FeatureControlSupported bool `json:"feature_control_supported"`
|
||||||
|
// DataTableSupported bool `json:"data_table_supported"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type SummaryInfo struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AtaSmartAttributes struct {
|
||||||
|
// Revision int `json:"revision"`
|
||||||
|
Table []AtaSmartAttribute `json:"table"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AtaSmartAttribute struct {
|
||||||
|
ID uint16 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value uint16 `json:"value"`
|
||||||
|
Worst uint16 `json:"worst"`
|
||||||
|
Thresh uint16 `json:"thresh"`
|
||||||
|
WhenFailed string `json:"when_failed"`
|
||||||
|
Flags AttributeFlags `json:"flags"`
|
||||||
|
Raw RawValue `json:"raw"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AttributeFlags struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
Prefailure bool `json:"prefailure"`
|
||||||
|
UpdatedOnline bool `json:"updated_online"`
|
||||||
|
Performance bool `json:"performance"`
|
||||||
|
ErrorRate bool `json:"error_rate"`
|
||||||
|
EventCount bool `json:"event_count"`
|
||||||
|
AutoKeep bool `json:"auto_keep"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawValue struct {
|
||||||
|
Value uint64 `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type PowerOnTimeInfo struct {
|
||||||
|
// Hours uint32 `json:"hours"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type TemperatureInfo struct {
|
||||||
|
Current uint8 `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type SelectiveSelfTestTable struct {
|
||||||
|
// LbaMin int `json:"lba_min"`
|
||||||
|
// LbaMax int `json:"lba_max"`
|
||||||
|
// Status StatusInfo `json:"status"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type SelectiveSelfTestFlags struct {
|
||||||
|
// Value int `json:"value"`
|
||||||
|
// RemainderScanEnabled bool `json:"remainder_scan_enabled"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type AtaSmartSelectiveSelfTestLog struct {
|
||||||
|
// Revision int `json:"revision"`
|
||||||
|
// Table []SelectiveSelfTestTable `json:"table"`
|
||||||
|
// Flags SelectiveSelfTestFlags `json:"flags"`
|
||||||
|
// PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// BaseSmartInfo contains common fields shared between SATA and NVMe drives
|
||||||
|
// type BaseSmartInfo struct {
|
||||||
|
// Device DeviceInfo `json:"device"`
|
||||||
|
// ModelName string `json:"model_name"`
|
||||||
|
// SerialNumber string `json:"serial_number"`
|
||||||
|
// FirmwareVersion string `json:"firmware_version"`
|
||||||
|
// UserCapacity UserCapacity `json:"user_capacity"`
|
||||||
|
// LogicalBlockSize int `json:"logical_block_size"`
|
||||||
|
// LocalTime LocalTime `json:"local_time"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type SmartctlInfoLegacy struct {
|
||||||
|
Version VersionInfo `json:"version"`
|
||||||
|
SvnRevision string `json:"svn_revision"`
|
||||||
|
PlatformInfo string `json:"platform_info"`
|
||||||
|
BuildInfo string `json:"build_info"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
ExitStatus int `json:"exit_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartInfoForSata struct {
|
||||||
|
// JSONFormatVersion VersionInfo `json:"json_format_version"`
|
||||||
|
Smartctl SmartctlInfoLegacy `json:"smartctl"`
|
||||||
|
Device DeviceInfo `json:"device"`
|
||||||
|
// ModelFamily string `json:"model_family"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
// Wwn WwnInfo `json:"wwn"`
|
||||||
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
|
UserCapacity UserCapacity `json:"user_capacity"`
|
||||||
|
// LogicalBlockSize int `json:"logical_block_size"`
|
||||||
|
// PhysicalBlockSize int `json:"physical_block_size"`
|
||||||
|
// RotationRate int `json:"rotation_rate"`
|
||||||
|
// FormFactor FormFactorInfo `json:"form_factor"`
|
||||||
|
// Trim TrimInfo `json:"trim"`
|
||||||
|
// InSmartctlDatabase bool `json:"in_smartctl_database"`
|
||||||
|
// AtaVersion AtaVersionInfo `json:"ata_version"`
|
||||||
|
// SataVersion VersionStringInfo `json:"sata_version"`
|
||||||
|
// InterfaceSpeed InterfaceSpeedInfo `json:"interface_speed"`
|
||||||
|
// LocalTime LocalTime `json:"local_time"`
|
||||||
|
SmartStatus SmartStatusInfo `json:"smart_status"`
|
||||||
|
// AtaSmartData AtaSmartData `json:"ata_smart_data"`
|
||||||
|
// AtaSctCapabilities AtaSctCapabilities `json:"ata_sct_capabilities"`
|
||||||
|
AtaSmartAttributes AtaSmartAttributes `json:"ata_smart_attributes"`
|
||||||
|
// PowerOnTime PowerOnTimeInfo `json:"power_on_time"`
|
||||||
|
// PowerCycleCount uint16 `json:"power_cycle_count"`
|
||||||
|
Temperature TemperatureInfo `json:"temperature"`
|
||||||
|
// AtaSmartErrorLog AtaSmartErrorLog `json:"ata_smart_error_log"`
|
||||||
|
// AtaSmartSelfTestLog AtaSmartSelfTestLog `json:"ata_smart_self_test_log"`
|
||||||
|
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_self_test_log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type AtaSmartErrorLog struct {
|
||||||
|
// Summary SummaryInfo `json:"summary"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type AtaSmartSelfTestLog struct {
|
||||||
|
// Standard SummaryInfo `json:"standard"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type SmartctlInfoNvme struct {
|
||||||
|
Version VersionInfo `json:"version"`
|
||||||
|
SVNRevision string `json:"svn_revision"`
|
||||||
|
PlatformInfo string `json:"platform_info"`
|
||||||
|
BuildInfo string `json:"build_info"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
ExitStatus int `json:"exit_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// type NVMePCIVendor struct {
|
||||||
|
// ID int `json:"id"`
|
||||||
|
// SubsystemID int `json:"subsystem_id"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type SizeCapacityInfo struct {
|
||||||
|
// Blocks uint64 `json:"blocks"`
|
||||||
|
// Bytes uint64 `json:"bytes"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type EUI64Info struct {
|
||||||
|
// OUI int `json:"oui"`
|
||||||
|
// ExtID int `json:"ext_id"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
// type NVMeNamespace struct {
|
||||||
|
// ID uint32 `json:"id"`
|
||||||
|
// Size SizeCapacityInfo `json:"size"`
|
||||||
|
// Capacity SizeCapacityInfo `json:"capacity"`
|
||||||
|
// Utilization SizeCapacityInfo `json:"utilization"`
|
||||||
|
// FormattedLBASize uint32 `json:"formatted_lba_size"`
|
||||||
|
// EUI64 EUI64Info `json:"eui64"`
|
||||||
|
// }
|
||||||
|
|
||||||
|
type SmartStatusInfoNvme struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
NVMe SmartStatusNVMe `json:"nvme"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartStatusNVMe struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NVMeSmartHealthInformationLog struct {
|
||||||
|
CriticalWarning uint `json:"critical_warning"`
|
||||||
|
Temperature uint8 `json:"temperature"`
|
||||||
|
AvailableSpare uint `json:"available_spare"`
|
||||||
|
AvailableSpareThreshold uint `json:"available_spare_threshold"`
|
||||||
|
PercentageUsed uint8 `json:"percentage_used"`
|
||||||
|
DataUnitsRead uint64 `json:"data_units_read"`
|
||||||
|
DataUnitsWritten uint64 `json:"data_units_written"`
|
||||||
|
HostReads uint `json:"host_reads"`
|
||||||
|
HostWrites uint `json:"host_writes"`
|
||||||
|
ControllerBusyTime uint `json:"controller_busy_time"`
|
||||||
|
PowerCycles uint16 `json:"power_cycles"`
|
||||||
|
PowerOnHours uint32 `json:"power_on_hours"`
|
||||||
|
UnsafeShutdowns uint16 `json:"unsafe_shutdowns"`
|
||||||
|
MediaErrors uint `json:"media_errors"`
|
||||||
|
NumErrLogEntries uint `json:"num_err_log_entries"`
|
||||||
|
WarningTempTime uint `json:"warning_temp_time"`
|
||||||
|
CriticalCompTime uint `json:"critical_comp_time"`
|
||||||
|
TemperatureSensors []uint8 `json:"temperature_sensors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartInfoForNvme struct {
|
||||||
|
// JSONFormatVersion VersionInfo `json:"json_format_version"`
|
||||||
|
Smartctl SmartctlInfoNvme `json:"smartctl"`
|
||||||
|
Device DeviceInfo `json:"device"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
|
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||||
|
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
||||||
|
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||||
|
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
||||||
|
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||||
|
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||||
|
// NVMeNumberOfNamespaces uint8 `json:"nvme_number_of_namespaces"`
|
||||||
|
// NVMeNamespaces []NVMeNamespace `json:"nvme_namespaces"`
|
||||||
|
UserCapacity UserCapacity `json:"user_capacity"`
|
||||||
|
// LogicalBlockSize int `json:"logical_block_size"`
|
||||||
|
// LocalTime LocalTime `json:"local_time"`
|
||||||
|
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
|
||||||
|
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
|
||||||
|
Temperature TemperatureInfoNvme `json:"temperature"`
|
||||||
|
PowerCycleCount uint16 `json:"power_cycle_count"`
|
||||||
|
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemperatureInfoNvme struct {
|
||||||
|
Current int `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PowerOnTimeInfoNvme struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartData struct {
|
||||||
|
// ModelFamily string `json:"mf,omitempty" cbor:"0,keyasint,omitempty"`
|
||||||
|
ModelName string `json:"mn,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
SerialNumber string `json:"sn,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
FirmwareVersion string `json:"fv,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
Capacity uint64 `json:"c,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
SmartStatus string `json:"s,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
DiskName string `json:"dn,omitempty" cbor:"6,keyasint,omitempty"`
|
||||||
|
DiskType string `json:"dt,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
|
Temperature uint8 `json:"t,omitempty" cbor:"8,keyasint,omitempty"`
|
||||||
|
Attributes []*SmartAttribute `json:"a,omitempty" cbor:"9,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartAttribute struct {
|
||||||
|
ID uint16 `json:"id,omitempty" cbor:"0,keyasint,omitempty"`
|
||||||
|
Name string `json:"n" cbor:"1,keyasint"`
|
||||||
|
Value uint16 `json:"v,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
Worst uint16 `json:"w,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
Threshold uint16 `json:"t,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
|
RawValue uint64 `json:"rv" cbor:"5,keyasint"`
|
||||||
|
RawString string `json:"rs,omitempty" cbor:"6,keyasint,omitempty"`
|
||||||
|
WhenFailed string `json:"wf,omitempty" cbor:"7,keyasint,omitempty"`
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ type Stats struct {
|
|||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
Battery [2]uint8 `json:"bat,omitzero" cbor:"29,keyasint,omitzero"` // [percent, charge state, current]
|
||||||
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
MaxMem float64 `json:"mm,omitempty" cbor:"30,keyasint,omitempty"`
|
||||||
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
NetworkInterfaces map[string][4]uint64 `json:"ni,omitempty" cbor:"31,keyasint,omitempty"` // [upload bytes, download bytes, total upload, total download]
|
||||||
|
DiskIO [2]uint64 `json:"dio,omitzero" cbor:"32,keyasint,omitzero"` // [read bytes, write bytes]
|
||||||
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -53,12 +55,14 @@ type GPUData struct {
|
|||||||
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
Power float64 `json:"p,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
Count float64 `json:"-"`
|
Count float64 `json:"-"`
|
||||||
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
|
Engines map[string]float64 `json:"e,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
PowerPkg float64 `json:"pp,omitempty" cbor:"6,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
Time time.Time `json:"-"`
|
Time time.Time `json:"-"`
|
||||||
Root bool `json:"-"`
|
Root bool `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
|
Name string `json:"-"`
|
||||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
TotalRead uint64 `json:"-"`
|
TotalRead uint64 `json:"-"`
|
||||||
@@ -67,6 +71,11 @@ type FsStats struct {
|
|||||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"4,keyasint,omitempty"`
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"5,keyasint,omitempty"`
|
||||||
|
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||||
|
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||||
|
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||||
|
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
|
||||||
|
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -93,7 +94,7 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
|
|||||||
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
// verifyWsConn verifies the WebSocket connection using the agent's fingerprint and
|
||||||
// SSH key signature, then adds the system to the system manager.
|
// SSH key signature, then adds the system to the system manager.
|
||||||
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.FingerprintRecord) (err error) {
|
||||||
wsConn := ws.NewWsConnection(conn)
|
wsConn := ws.NewWsConnection(conn, acr.agentSemVer)
|
||||||
|
|
||||||
// must set wsConn in connection store before the read loop
|
// must set wsConn in connection store before the read loop
|
||||||
conn.Session().Store("wsConn", wsConn)
|
conn.Session().Store("wsConn", wsConn)
|
||||||
@@ -112,7 +113,7 @@ func (acr *agentConnectRequest) verifyWsConn(conn *gws.Conn, fpRecords []ws.Fing
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
agentFingerprint, err := wsConn.GetFingerprint(acr.token, signer, acr.isUniversalToken)
|
agentFingerprint, err := wsConn.GetFingerprint(context.Background(), acr.token, signer, acr.isUniversalToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -267,9 +268,12 @@ func (acr *agentConnectRequest) createSystem(agentFingerprint common.Fingerprint
|
|||||||
if agentFingerprint.Port == "" {
|
if agentFingerprint.Port == "" {
|
||||||
agentFingerprint.Port = "45876"
|
agentFingerprint.Port = "45876"
|
||||||
}
|
}
|
||||||
|
if agentFingerprint.Name == "" {
|
||||||
|
agentFingerprint.Name = agentFingerprint.Hostname
|
||||||
|
}
|
||||||
// create new record
|
// create new record
|
||||||
systemRecord := core.NewRecord(systemsCollection)
|
systemRecord := core.NewRecord(systemsCollection)
|
||||||
systemRecord.Set("name", agentFingerprint.Hostname)
|
systemRecord.Set("name", agentFingerprint.Name)
|
||||||
systemRecord.Set("host", remoteAddr)
|
systemRecord.Set("host", remoteAddr)
|
||||||
systemRecord.Set("port", agentFingerprint.Port)
|
systemRecord.Set("port", agentFingerprint.Port)
|
||||||
systemRecord.Set("users", []string{acr.userId})
|
systemRecord.Set("users", []string{acr.userId})
|
||||||
|
|||||||
@@ -120,7 +120,19 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
if err := setCollectionAuthSettings(e.App); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setCollectionAuthSettings sets up default authentication settings for the app
|
||||||
|
func setCollectionAuthSettings(app core.App) error {
|
||||||
|
usersCollection, err := app.FindCollectionByNameOrId("users")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -128,10 +140,6 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
|
|
||||||
if usersCollection.OAuth2.Enabled {
|
|
||||||
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
|
|
||||||
}
|
|
||||||
// allow oauth user creation if USER_CREATION is set
|
// allow oauth user creation if USER_CREATION is set
|
||||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||||
cr := "@request.context = 'oauth2'"
|
cr := "@request.context = 'oauth2'"
|
||||||
@@ -139,11 +147,22 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
} else {
|
} else {
|
||||||
usersCollection.CreateRule = nil
|
usersCollection.CreateRule = nil
|
||||||
}
|
}
|
||||||
if err := e.App.Save(usersCollection); err != nil {
|
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||||
|
mfaOtp, _ := GetEnv("MFA_OTP")
|
||||||
|
usersCollection.OTP.Length = 6
|
||||||
|
superusersCollection.OTP.Length = 6
|
||||||
|
usersCollection.OTP.Enabled = mfaOtp == "true"
|
||||||
|
usersCollection.MFA.Enabled = mfaOtp == "true"
|
||||||
|
superusersCollection.OTP.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
|
||||||
|
superusersCollection.MFA.Enabled = mfaOtp == "true" || mfaOtp == "superusers"
|
||||||
|
if err := app.Save(superusersCollection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := app.Save(usersCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
|
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
|
||||||
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems")
|
systemsCollection, err := app.FindCollectionByNameOrId("systems")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -158,10 +177,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
systemsCollection.ViewRule = &systemsReadRule
|
systemsCollection.ViewRule = &systemsReadRule
|
||||||
systemsCollection.UpdateRule = &updateDeleteRule
|
systemsCollection.UpdateRule = &updateDeleteRule
|
||||||
systemsCollection.DeleteRule = &updateDeleteRule
|
systemsCollection.DeleteRule = &updateDeleteRule
|
||||||
if err := e.App.Save(systemsCollection); err != nil {
|
return app.Save(systemsCollection)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
@@ -236,7 +252,15 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
|
// get SMART data
|
||||||
|
apiAuth.GET("/smart", h.getSmartData)
|
||||||
|
// /containers routes
|
||||||
|
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
|
// get container logs
|
||||||
|
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
||||||
|
// get container info
|
||||||
|
apiAuth.GET("/containers/info", h.getContainerInfo)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +291,59 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, response)
|
return e.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// containerRequestHandler handles both container logs and info requests
|
||||||
|
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
|
||||||
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
|
containerID := e.Request.URL.Query().Get("container")
|
||||||
|
|
||||||
|
if systemID == "" || containerID == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := fetchFunc(system, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContainerLogs handles GET /api/beszel/containers/logs requests
|
||||||
|
func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
|
||||||
|
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
|
||||||
|
return system.FetchContainerLogsFromAgent(containerID)
|
||||||
|
}, "logs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
|
||||||
|
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
|
||||||
|
return system.FetchContainerInfoFromAgent(containerID)
|
||||||
|
}, "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSmartData handles GET /api/beszel/smart requests
|
||||||
|
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
||||||
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
|
if systemID == "" {
|
||||||
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||||
|
}
|
||||||
|
system, err := h.sm.GetSystem(systemID)
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||||
|
}
|
||||||
|
data, err := system.FetchSmartDataFromAgent()
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
|
return e.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns signer
|
// generates key pair if it doesn't exist and returns signer
|
||||||
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||||
if h.signer != nil {
|
if h.signer != nil {
|
||||||
|
|||||||
@@ -449,6 +449,47 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /containers/logs - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/containers/logs?container=test-container",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"system and container parameters are required"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /containers/logs - with auth but missing container param should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/containers/logs?system=test-system",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"system and container parameters are required"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /containers/logs - with auth but invalid system should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"system not found"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
|
||||||
// Auth Optional Routes - Should work without authentication
|
// Auth Optional Routes - Should work without authentication
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -107,7 +110,7 @@ func (sys *System) update() error {
|
|||||||
sys.handlePaused()
|
sys.handlePaused()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data, err := sys.fetchDataFromAgent()
|
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = sys.createRecords(data)
|
_, err = sys.createRecords(data)
|
||||||
}
|
}
|
||||||
@@ -134,41 +137,81 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hub := sys.manager.hub
|
hub := sys.manager.hub
|
||||||
// add system_stats and container_stats records
|
err = hub.RunInTransaction(func(txApp core.App) error {
|
||||||
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
// add system_stats and container_stats records
|
||||||
if err != nil {
|
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
|
||||||
systemStatsRecord.Set("system", systemRecord.Id)
|
|
||||||
systemStatsRecord.Set("stats", data.Stats)
|
|
||||||
systemStatsRecord.Set("type", "1m")
|
|
||||||
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// add new container_stats record
|
|
||||||
if len(data.Containers) > 0 {
|
|
||||||
containerStatsCollection, err := hub.FindCachedCollectionByNameOrId("container_stats")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
|
||||||
containerStatsRecord.Set("system", systemRecord.Id)
|
|
||||||
containerStatsRecord.Set("stats", data.Containers)
|
|
||||||
containerStatsRecord.Set("type", "1m")
|
|
||||||
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
|
||||||
systemRecord.Set("status", up)
|
|
||||||
|
|
||||||
systemRecord.Set("info", data.Info)
|
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
||||||
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
systemStatsRecord.Set("system", systemRecord.Id)
|
||||||
return nil, err
|
systemStatsRecord.Set("stats", data.Stats)
|
||||||
|
systemStatsRecord.Set("type", "1m")
|
||||||
|
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(data.Containers) > 0 {
|
||||||
|
// add / update containers records
|
||||||
|
if data.Containers[0].Id != "" {
|
||||||
|
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// add new container_stats record
|
||||||
|
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
||||||
|
containerStatsRecord.Set("system", systemRecord.Id)
|
||||||
|
containerStatsRecord.Set("stats", data.Containers)
|
||||||
|
containerStatsRecord.Set("type", "1m")
|
||||||
|
if err := txApp.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
|
systemRecord.Set("info", data.Info)
|
||||||
|
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return systemRecord, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// createContainerRecords creates container records
|
||||||
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return systemRecord, nil
|
params := dbx.Params{
|
||||||
|
"system": systemId,
|
||||||
|
"updated": time.Now().UTC().UnixMilli(),
|
||||||
|
}
|
||||||
|
valueStrings := make([]string, 0, len(data))
|
||||||
|
for i, container := range data {
|
||||||
|
suffix := fmt.Sprintf("%d", i)
|
||||||
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:image%[1]s}, {:status%[1]s}, {:health%[1]s}, {:cpu%[1]s}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
|
||||||
|
params["id"+suffix] = container.Id
|
||||||
|
params["name"+suffix] = container.Name
|
||||||
|
params["image"+suffix] = container.Image
|
||||||
|
params["status"+suffix] = container.Status
|
||||||
|
params["health"+suffix] = container.Health
|
||||||
|
params["cpu"+suffix] = container.Cpu
|
||||||
|
params["memory"+suffix] = container.Mem
|
||||||
|
params["net"+suffix] = container.NetworkSent + container.NetworkRecv
|
||||||
|
}
|
||||||
|
queryString := fmt.Sprintf(
|
||||||
|
"INSERT INTO containers (id, system, name, image, status, health, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, image = excluded.image, status = excluded.status, health = excluded.health, cpu = excluded.cpu, memory = excluded.memory, net = excluded.net, updated = excluded.updated",
|
||||||
|
strings.Join(valueStrings, ","),
|
||||||
|
)
|
||||||
|
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRecord retrieves the system record from the database.
|
// getRecord retrieves the system record from the database.
|
||||||
@@ -209,13 +252,13 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
|||||||
|
|
||||||
// fetchDataFromAgent attempts to fetch data from the agent,
|
// fetchDataFromAgent attempts to fetch data from the agent,
|
||||||
// prioritizing WebSocket if available.
|
// prioritizing WebSocket if available.
|
||||||
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
if sys.data == nil {
|
if sys.data == nil {
|
||||||
sys.data = &system.CombinedData{}
|
sys.data = &system.CombinedData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
wsData, err := sys.fetchDataViaWebSocket()
|
wsData, err := sys.fetchDataViaWebSocket(options)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return wsData, nil
|
return wsData, nil
|
||||||
}
|
}
|
||||||
@@ -223,82 +266,214 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
|||||||
sys.closeWebSocketConnection()
|
sys.closeWebSocketConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
sshData, err := sys.fetchDataViaSSH()
|
sshData, err := sys.fetchDataViaSSH(options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sshData, nil
|
return sshData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sys *System) fetchDataViaWebSocket() (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||||
return nil, errors.New("no websocket connection")
|
return nil, errors.New("no websocket connection")
|
||||||
}
|
}
|
||||||
err := sys.WsConn.RequestSystemData(sys.data)
|
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sys.data, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
|
||||||
|
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
||||||
|
var result string
|
||||||
|
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
stdin, stdinErr := session.StdinPipe()
|
||||||
|
if stdinErr != nil {
|
||||||
|
return false, stdinErr
|
||||||
|
}
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req := common.HubRequest[any]{Action: action, Data: requestData}
|
||||||
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
|
_ = stdin.Close()
|
||||||
|
var resp common.AgentResponse
|
||||||
|
err = cbor.NewDecoder(stdout).Decode(&resp)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if resp.String == nil {
|
||||||
|
return false, errors.New(errorMsg)
|
||||||
|
}
|
||||||
|
result = *resp.String
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchContainerInfoFromAgent fetches container info from the agent
|
||||||
|
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
||||||
|
// fetch via websocket
|
||||||
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return sys.WsConn.RequestContainerInfo(ctx, containerID)
|
||||||
|
}
|
||||||
|
// fetch via SSH
|
||||||
|
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchContainerLogsFromAgent fetches container logs from the agent
|
||||||
|
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
||||||
|
// fetch via websocket
|
||||||
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return sys.WsConn.RequestContainerLogs(ctx, containerID)
|
||||||
|
}
|
||||||
|
// fetch via SSH
|
||||||
|
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
|
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
||||||
|
// fetch via websocket
|
||||||
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return sys.WsConn.RequestSmartData(ctx)
|
||||||
|
}
|
||||||
|
// fetch via SSH
|
||||||
|
var result map[string]any
|
||||||
|
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
stdin, stdinErr := session.StdinPipe()
|
||||||
|
if stdinErr != nil {
|
||||||
|
return false, stdinErr
|
||||||
|
}
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
req := common.HubRequest[any]{Action: common.GetSmartData}
|
||||||
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
|
_ = stdin.Close()
|
||||||
|
var resp common.AgentResponse
|
||||||
|
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// Convert to generic map for JSON response
|
||||||
|
result = make(map[string]any, len(resp.SmartData))
|
||||||
|
for k, v := range resp.SmartData {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
// fetchDataViaSSH handles fetching data using SSH.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
// This function encapsulates the original SSH logic.
|
// This function encapsulates the original SSH logic.
|
||||||
// It updates sys.data directly upon successful fetch.
|
// It updates sys.data directly upon successful fetch.
|
||||||
func (sys *System) fetchDataViaSSH() (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
maxRetries := 1
|
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
||||||
if sys.client == nil || sys.Status == down {
|
|
||||||
if err := sys.createSSHClient(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
session, err := sys.createSessionWithTimeout(4 * time.Second)
|
|
||||||
if err != nil {
|
|
||||||
if attempt >= maxRetries {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
|
|
||||||
sys.closeSSHConnection()
|
|
||||||
// Reset format detection on connection failure - agent might have been upgraded
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
defer session.Close()
|
|
||||||
|
|
||||||
stdout, err := session.StdoutPipe()
|
stdout, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
stdin, stdinErr := session.StdinPipe()
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
*sys.data = system.CombinedData{}
|
*sys.data = system.CombinedData{}
|
||||||
|
|
||||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||||
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||||
} else {
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
err = json.NewDecoder(stdout).Decode(sys.data)
|
_ = stdin.Close()
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
var resp common.AgentResponse
|
||||||
sys.closeSSHConnection()
|
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
||||||
if attempt < maxRetries {
|
*sys.data = *resp.SystemData
|
||||||
continue
|
if err := session.Wait(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the session to complete
|
var decodeErr error
|
||||||
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
|
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
|
} else {
|
||||||
|
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decodeErr != nil {
|
||||||
|
return true, decodeErr
|
||||||
|
}
|
||||||
|
|
||||||
if err := session.Wait(); err != nil {
|
if err := session.Wait(); err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return sys.data, nil
|
return false, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// this should never be reached due to the return in the loop
|
return sys.data, nil
|
||||||
return nil, fmt.Errorf("failed to fetch data")
|
}
|
||||||
|
|
||||||
|
// runSSHOperation establishes an SSH session and executes the provided operation.
|
||||||
|
// The operation can request a retry by returning true as the first return value.
|
||||||
|
func (sys *System) runSSHOperation(timeout time.Duration, retries int, operation func(*ssh.Session) (bool, error)) error {
|
||||||
|
for attempt := 0; attempt <= retries; attempt++ {
|
||||||
|
if sys.client == nil || sys.Status == down {
|
||||||
|
if err := sys.createSSHClient(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := sys.createSessionWithTimeout(timeout)
|
||||||
|
if err != nil {
|
||||||
|
if attempt >= retries {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
|
||||||
|
sys.closeSSHConnection()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
retry, opErr := func() (bool, error) {
|
||||||
|
defer session.Close()
|
||||||
|
return operation(session)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if opErr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if retry {
|
||||||
|
sys.closeSSHConnection()
|
||||||
|
if attempt < retries {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("ssh operation failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
// createSSHClient creates a new SSH client for the system
|
||||||
@@ -379,11 +554,11 @@ func extractAgentVersion(versionString string) (semver.Version, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getJitter returns a channel that will be triggered after a random delay
|
// getJitter returns a channel that will be triggered after a random delay
|
||||||
// between 40% and 90% of the interval.
|
// between 51% and 95% of the interval.
|
||||||
// This is used to stagger the initial WebSocket connections to prevent clustering.
|
// This is used to stagger the initial WebSocket connections to prevent clustering.
|
||||||
func getJitter() <-chan time.Time {
|
func getJitter() <-chan time.Time {
|
||||||
minPercent := 40
|
minPercent := 51
|
||||||
maxPercent := 90
|
maxPercent := 95
|
||||||
jitterRange := maxPercent - minPercent
|
jitterRange := maxPercent - minPercent
|
||||||
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
|
msDelay := (interval * minPercent / 100) + rand.Intn(interval*jitterRange/100)
|
||||||
return time.After(time.Duration(msDelay) * time.Millisecond)
|
return time.After(time.Duration(msDelay) * time.Millisecond)
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSystem returns a system by ID from the store
|
||||||
|
func (sm *SystemManager) GetSystem(systemID string) (*System, error) {
|
||||||
|
sys, ok := sm.systems.GetOk(systemID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("system not found")
|
||||||
|
}
|
||||||
|
return sys, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
// Initialize sets up the system manager by binding event hooks and starting existing systems.
|
||||||
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
// It configures SSH client settings and begins monitoring all non-paused systems from the database.
|
||||||
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
// Systems are started with staggered delays to prevent overwhelming the hub during startup.
|
||||||
@@ -106,6 +115,8 @@ func (sm *SystemManager) bindEventHooks() {
|
|||||||
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
|
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
|
||||||
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
|
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
|
||||||
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
sm.hub.OnRecordAfterUpdateSuccess("fingerprints").BindFunc(sm.onTokenRotated)
|
||||||
|
sm.hub.OnRealtimeSubscribeRequest().BindFunc(sm.onRealtimeSubscribeRequest)
|
||||||
|
sm.hub.OnRealtimeConnectRequest().BindFunc(sm.onRealtimeConnectRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// onTokenRotated handles fingerprint token rotation events.
|
// onTokenRotated handles fingerprint token rotation events.
|
||||||
|
|||||||
188
internal/hub/systems/system_realtime.go
Normal file
188
internal/hub/systems/system_realtime.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/subscriptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
type subscriptionInfo struct {
|
||||||
|
subscription string
|
||||||
|
connectedClients uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
activeSubscriptions = make(map[string]*subscriptionInfo)
|
||||||
|
workerRunning bool
|
||||||
|
realtimeTicker *time.Ticker
|
||||||
|
tickerStopChan chan struct{}
|
||||||
|
realtimeMutex sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// onRealtimeConnectRequest handles client connection events for realtime subscriptions.
|
||||||
|
// It cleans up existing subscriptions when a client connects.
|
||||||
|
func (sm *SystemManager) onRealtimeConnectRequest(e *core.RealtimeConnectRequestEvent) error {
|
||||||
|
// after e.Next() is the client disconnection
|
||||||
|
e.Next()
|
||||||
|
subscriptions := e.Client.Subscriptions()
|
||||||
|
for k := range subscriptions {
|
||||||
|
sm.removeRealtimeSubscription(k, subscriptions[k])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRealtimeSubscribeRequest handles client subscription events for realtime metrics.
|
||||||
|
// It tracks new subscriptions and unsubscriptions to manage the realtime worker lifecycle.
|
||||||
|
func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeRequestEvent) error {
|
||||||
|
oldSubs := e.Client.Subscriptions()
|
||||||
|
// after e.Next() is the result of the subscribe request
|
||||||
|
err := e.Next()
|
||||||
|
newSubs := e.Client.Subscriptions()
|
||||||
|
|
||||||
|
// handle new subscriptions
|
||||||
|
for k, options := range newSubs {
|
||||||
|
if _, ok := oldSubs[k]; !ok {
|
||||||
|
if strings.HasPrefix(k, "rt_metrics") {
|
||||||
|
systemId := options.Query["system"]
|
||||||
|
if _, ok := activeSubscriptions[systemId]; !ok {
|
||||||
|
activeSubscriptions[systemId] = &subscriptionInfo{
|
||||||
|
subscription: k,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activeSubscriptions[systemId].connectedClients += 1
|
||||||
|
sm.onRealtimeSubscriptionAdded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// handle unsubscriptions
|
||||||
|
for k := range oldSubs {
|
||||||
|
if _, ok := newSubs[k]; !ok {
|
||||||
|
sm.removeRealtimeSubscription(k, oldSubs[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
|
||||||
|
// It ensures only one worker runs at a time and creates the ticker for periodic data fetching.
|
||||||
|
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
|
||||||
|
realtimeMutex.Lock()
|
||||||
|
defer realtimeMutex.Unlock()
|
||||||
|
|
||||||
|
// Start the worker if it's not already running
|
||||||
|
if !workerRunning {
|
||||||
|
workerRunning = true
|
||||||
|
// Create a new stop channel for this worker instance
|
||||||
|
tickerStopChan = make(chan struct{})
|
||||||
|
go sm.startRealtimeWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no ticker exists, create one
|
||||||
|
if realtimeTicker == nil {
|
||||||
|
realtimeTicker = time.NewTicker(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
|
||||||
|
// This prevents unnecessary resource usage when no clients are listening for realtime data.
|
||||||
|
func (sm *SystemManager) checkSubscriptions() {
|
||||||
|
if !workerRunning || len(activeSubscriptions) > 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
realtimeMutex.Lock()
|
||||||
|
defer realtimeMutex.Unlock()
|
||||||
|
|
||||||
|
// Signal the worker to stop
|
||||||
|
if tickerStopChan != nil {
|
||||||
|
select {
|
||||||
|
case tickerStopChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if realtimeTicker != nil {
|
||||||
|
realtimeTicker.Stop()
|
||||||
|
realtimeTicker = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark worker as stopped (will be reset when next subscription comes in)
|
||||||
|
workerRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeRealtimeSubscription removes a realtime subscription and checks if the worker should be stopped.
|
||||||
|
// It only processes subscriptions with the "rt_metrics" prefix and triggers cleanup when subscriptions are removed.
|
||||||
|
func (sm *SystemManager) removeRealtimeSubscription(subscription string, options subscriptions.SubscriptionOptions) {
|
||||||
|
if strings.HasPrefix(subscription, "rt_metrics") {
|
||||||
|
systemId := options.Query["system"]
|
||||||
|
if info, ok := activeSubscriptions[systemId]; ok {
|
||||||
|
info.connectedClients -= 1
|
||||||
|
if info.connectedClients <= 0 {
|
||||||
|
delete(activeSubscriptions, systemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sm.checkSubscriptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRealtimeWorker runs the main loop for fetching realtime data from agents.
|
||||||
|
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
|
||||||
|
func (sm *SystemManager) startRealtimeWorker() {
|
||||||
|
sm.fetchRealtimeDataAndNotify()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-tickerStopChan:
|
||||||
|
return
|
||||||
|
case <-realtimeTicker.C:
|
||||||
|
// Check if ticker is still valid (might have been stopped)
|
||||||
|
if realtimeTicker == nil || len(activeSubscriptions) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// slog.Debug("activeSubscriptions", "count", len(activeSubscriptions))
|
||||||
|
sm.fetchRealtimeDataAndNotify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||||
|
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||||
|
for systemId, info := range activeSubscriptions {
|
||||||
|
system, err := sm.GetSystem(systemId)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
data, err := system.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: 1000})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bytes, err := json.Marshal(data)
|
||||||
|
if err == nil {
|
||||||
|
notify(sm.hub, info.subscription, bytes)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify broadcasts realtime data to all clients subscribed to a specific subscription.
|
||||||
|
// It iterates through all connected clients and sends the data only to those with matching subscriptions.
|
||||||
|
func notify(app core.App, subscription string, data []byte) error {
|
||||||
|
message := subscriptions.Message{
|
||||||
|
Name: subscription,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
for _, client := range app.SubscriptionsBroker().Clients() {
|
||||||
|
if !client.HasSubscription(subscription) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client.Send(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
199
internal/hub/ws/handlers.go
Normal file
199
internal/hub/ws/handlers.go
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResponseHandler defines interface for handling agent responses
|
||||||
|
type ResponseHandler interface {
|
||||||
|
Handle(agentResponse common.AgentResponse) error
|
||||||
|
HandleLegacy(rawData []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
||||||
|
type BaseHandler struct{}
|
||||||
|
|
||||||
|
func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
||||||
|
return errors.New("legacy format not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// systemDataHandler implements ResponseHandler for system data requests
|
||||||
|
type systemDataHandler struct {
|
||||||
|
data *system.CombinedData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *systemDataHandler) HandleLegacy(rawData []byte) error {
|
||||||
|
return cbor.Unmarshal(rawData, h.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *systemDataHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
|
if agentResponse.SystemData != nil {
|
||||||
|
*h.data = *agentResponse.SystemData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestSystemData requests system metrics from the agent and unmarshals the response.
|
||||||
|
func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedData, options common.DataRequestOptions) error {
|
||||||
|
if !ws.IsConnected() {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ws.requestManager.SendRequest(ctx, common.GetData, options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := &systemDataHandler{data: data}
|
||||||
|
return ws.handleAgentRequest(req, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// stringResponseHandler is a generic handler for string responses from agents
|
||||||
|
type stringResponseHandler struct {
|
||||||
|
BaseHandler
|
||||||
|
value string
|
||||||
|
errorMsg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
|
if agentResponse.String == nil {
|
||||||
|
return errors.New(h.errorMsg)
|
||||||
|
}
|
||||||
|
h.value = *agentResponse.String
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket
|
||||||
|
func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
||||||
|
if !ws.IsConnected() {
|
||||||
|
return "", gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ws.requestManager.SendRequest(ctx, action, requestData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := &stringResponseHandler{errorMsg: errorMsg}
|
||||||
|
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestContainerLogs requests logs for a specific container via WebSocket.
|
||||||
|
func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) {
|
||||||
|
return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestContainerInfo requests information about a specific container via WebSocket.
|
||||||
|
func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) {
|
||||||
|
return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// RequestSmartData requests SMART data via WebSocket.
|
||||||
|
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
||||||
|
if !ws.IsConnected() {
|
||||||
|
return nil, gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
req, err := ws.requestManager.SendRequest(ctx, common.GetSmartData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
handler := ResponseHandler(&smartDataHandler{result: &result})
|
||||||
|
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartDataHandler parses SMART data map from AgentResponse
|
||||||
|
type smartDataHandler struct {
|
||||||
|
BaseHandler
|
||||||
|
result *map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
|
if agentResponse.SmartData == nil {
|
||||||
|
return errors.New("no SMART data in response")
|
||||||
|
}
|
||||||
|
// convert to map[string]any for transport convenience in hub layer
|
||||||
|
out := make(map[string]any, len(agentResponse.SmartData))
|
||||||
|
for k, v := range agentResponse.SmartData {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
*h.result = out
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
|
type fingerprintHandler struct {
|
||||||
|
result *common.FingerprintResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fingerprintHandler) HandleLegacy(rawData []byte) error {
|
||||||
|
return cbor.Unmarshal(rawData, h.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *fingerprintHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
|
if agentResponse.Fingerprint != nil {
|
||||||
|
*h.result = *agentResponse.Fingerprint
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("no fingerprint data in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
|
||||||
|
func (ws *WsConn) GetFingerprint(ctx context.Context, token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
|
||||||
|
if !ws.IsConnected() {
|
||||||
|
return common.FingerprintResponse{}, gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := []byte(token)
|
||||||
|
signature, err := signer.Sign(nil, challenge)
|
||||||
|
if err != nil {
|
||||||
|
return common.FingerprintResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ws.requestManager.SendRequest(ctx, common.CheckFingerprint, common.FingerprintRequest{
|
||||||
|
Signature: signature.Blob,
|
||||||
|
NeedSysInfo: needSysInfo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return common.FingerprintResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result common.FingerprintResponse
|
||||||
|
handler := &fingerprintHandler{result: &result}
|
||||||
|
err = ws.handleAgentRequest(req, handler)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
186
internal/hub/ws/request_manager.go
Normal file
186
internal/hub/ws/request_manager.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestID uniquely identifies a request
|
||||||
|
type RequestID uint32
|
||||||
|
|
||||||
|
// PendingRequest tracks an in-flight request
|
||||||
|
type PendingRequest struct {
|
||||||
|
ID RequestID
|
||||||
|
ResponseCh chan *gws.Message
|
||||||
|
Context context.Context
|
||||||
|
Cancel context.CancelFunc
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestManager handles concurrent requests to an agent
|
||||||
|
type RequestManager struct {
|
||||||
|
sync.RWMutex
|
||||||
|
conn *gws.Conn
|
||||||
|
pendingReqs map[RequestID]*PendingRequest
|
||||||
|
nextID atomic.Uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRequestManager creates a new request manager for a WebSocket connection
|
||||||
|
func NewRequestManager(conn *gws.Conn) *RequestManager {
|
||||||
|
rm := &RequestManager{
|
||||||
|
conn: conn,
|
||||||
|
pendingReqs: make(map[RequestID]*PendingRequest),
|
||||||
|
}
|
||||||
|
return rm
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest sends a request and returns a channel for the response
|
||||||
|
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
||||||
|
reqID := RequestID(rm.nextID.Add(1))
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
|
||||||
|
req := &PendingRequest{
|
||||||
|
ID: reqID,
|
||||||
|
ResponseCh: make(chan *gws.Message, 1),
|
||||||
|
Context: reqCtx,
|
||||||
|
Cancel: cancel,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
rm.Lock()
|
||||||
|
rm.pendingReqs[reqID] = req
|
||||||
|
rm.Unlock()
|
||||||
|
|
||||||
|
hubReq := common.HubRequest[any]{
|
||||||
|
Id: (*uint32)(&reqID),
|
||||||
|
Action: action,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
if err := rm.sendMessage(hubReq); err != nil {
|
||||||
|
rm.cancelRequest(reqID)
|
||||||
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup watcher for timeout/cancellation
|
||||||
|
go rm.cleanupRequest(req)
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage encodes and sends a message over WebSocket
|
||||||
|
func (rm *RequestManager) sendMessage(data any) error {
|
||||||
|
if rm.conn == nil {
|
||||||
|
return gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := cbor.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rm.conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResponse processes a single response message
|
||||||
|
func (rm *RequestManager) handleResponse(message *gws.Message) {
|
||||||
|
var response common.AgentResponse
|
||||||
|
if err := cbor.Unmarshal(message.Data.Bytes(), &response); err != nil {
|
||||||
|
// Legacy response without ID - route to first pending request of any type
|
||||||
|
rm.routeLegacyResponse(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reqID := RequestID(*response.Id)
|
||||||
|
|
||||||
|
rm.RLock()
|
||||||
|
req, exists := rm.pendingReqs[reqID]
|
||||||
|
rm.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Request not found (might have timed out) - close the message
|
||||||
|
message.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case req.ResponseCh <- message:
|
||||||
|
// Message successfully delivered - the receiver will close it
|
||||||
|
rm.deleteRequest(reqID)
|
||||||
|
case <-req.Context.Done():
|
||||||
|
// Request was cancelled/timed out - close the message
|
||||||
|
message.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeLegacyResponse handles responses that don't have request IDs (backwards compatibility)
|
||||||
|
func (rm *RequestManager) routeLegacyResponse(message *gws.Message) {
|
||||||
|
// Snapshot the oldest pending request without holding the lock during send
|
||||||
|
rm.RLock()
|
||||||
|
var oldestReq *PendingRequest
|
||||||
|
for _, req := range rm.pendingReqs {
|
||||||
|
if oldestReq == nil || req.CreatedAt.Before(oldestReq.CreatedAt) {
|
||||||
|
oldestReq = req
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rm.RUnlock()
|
||||||
|
|
||||||
|
if oldestReq != nil {
|
||||||
|
select {
|
||||||
|
case oldestReq.ResponseCh <- message:
|
||||||
|
// Message successfully delivered - the receiver will close it
|
||||||
|
rm.deleteRequest(oldestReq.ID)
|
||||||
|
case <-oldestReq.Context.Done():
|
||||||
|
// Request was cancelled - close the message
|
||||||
|
message.Close()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No pending requests - close the message
|
||||||
|
message.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupRequest handles request timeout and cleanup
|
||||||
|
func (rm *RequestManager) cleanupRequest(req *PendingRequest) {
|
||||||
|
<-req.Context.Done()
|
||||||
|
rm.cancelRequest(req.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelRequest removes a request and cancels its context
|
||||||
|
func (rm *RequestManager) cancelRequest(reqID RequestID) {
|
||||||
|
rm.Lock()
|
||||||
|
defer rm.Unlock()
|
||||||
|
|
||||||
|
if req, exists := rm.pendingReqs[reqID]; exists {
|
||||||
|
req.Cancel()
|
||||||
|
delete(rm.pendingReqs, reqID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteRequest removes a request from the pending map without cancelling its context.
|
||||||
|
func (rm *RequestManager) deleteRequest(reqID RequestID) {
|
||||||
|
rm.Lock()
|
||||||
|
defer rm.Unlock()
|
||||||
|
delete(rm.pendingReqs, reqID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the request manager
|
||||||
|
func (rm *RequestManager) Close() {
|
||||||
|
rm.Lock()
|
||||||
|
defer rm.Unlock()
|
||||||
|
|
||||||
|
// Cancel all pending requests
|
||||||
|
for _, req := range rm.pendingReqs {
|
||||||
|
req.Cancel()
|
||||||
|
}
|
||||||
|
rm.pendingReqs = make(map[RequestID]*PendingRequest)
|
||||||
|
}
|
||||||
81
internal/hub/ws/request_manager_test.go
Normal file
81
internal/hub/ws/request_manager_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRequestManager_BasicFunctionality tests the request manager without mocking gws.Conn
|
||||||
|
func TestRequestManager_BasicFunctionality(t *testing.T) {
|
||||||
|
// We'll test the core logic without mocking the connection
|
||||||
|
// since the gws.Conn interface is complex to mock properly
|
||||||
|
|
||||||
|
t.Run("request ID generation", func(t *testing.T) {
|
||||||
|
// Test that request IDs are generated sequentially and uniquely
|
||||||
|
rm := &RequestManager{}
|
||||||
|
|
||||||
|
// Simulate multiple ID generations
|
||||||
|
id1 := rm.nextID.Add(1)
|
||||||
|
id2 := rm.nextID.Add(1)
|
||||||
|
id3 := rm.nextID.Add(1)
|
||||||
|
|
||||||
|
assert.NotEqual(t, id1, id2)
|
||||||
|
assert.NotEqual(t, id2, id3)
|
||||||
|
assert.Greater(t, id2, id1)
|
||||||
|
assert.Greater(t, id3, id2)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pending request tracking", func(t *testing.T) {
|
||||||
|
rm := &RequestManager{
|
||||||
|
pendingReqs: make(map[RequestID]*PendingRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initially no pending requests
|
||||||
|
assert.Equal(t, 0, rm.GetPendingCount())
|
||||||
|
|
||||||
|
// Add some fake pending requests
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req1 := &PendingRequest{
|
||||||
|
ID: RequestID(1),
|
||||||
|
Context: ctx,
|
||||||
|
Cancel: cancel,
|
||||||
|
}
|
||||||
|
req2 := &PendingRequest{
|
||||||
|
ID: RequestID(2),
|
||||||
|
Context: ctx,
|
||||||
|
Cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
rm.pendingReqs[req1.ID] = req1
|
||||||
|
rm.pendingReqs[req2.ID] = req2
|
||||||
|
|
||||||
|
assert.Equal(t, 2, rm.GetPendingCount())
|
||||||
|
|
||||||
|
// Remove one
|
||||||
|
delete(rm.pendingReqs, req1.ID)
|
||||||
|
assert.Equal(t, 1, rm.GetPendingCount())
|
||||||
|
|
||||||
|
// Remove all
|
||||||
|
delete(rm.pendingReqs, req2.ID)
|
||||||
|
assert.Equal(t, 0, rm.GetPendingCount())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("context cancellation", func(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Wait for context to timeout
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// Verify context was cancelled
|
||||||
|
assert.Equal(t, context.DeadlineExceeded, ctx.Err())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"weak"
|
"weak"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/blang/semver"
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -25,9 +25,10 @@ type Handler struct {
|
|||||||
|
|
||||||
// WsConn represents a WebSocket connection to an agent.
|
// WsConn represents a WebSocket connection to an agent.
|
||||||
type WsConn struct {
|
type WsConn struct {
|
||||||
conn *gws.Conn
|
conn *gws.Conn
|
||||||
responseChan chan *gws.Message
|
requestManager *RequestManager
|
||||||
DownChan chan struct{}
|
DownChan chan struct{}
|
||||||
|
agentVersion semver.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// FingerprintRecord is fingerprints collection record data in the hub
|
// FingerprintRecord is fingerprints collection record data in the hub
|
||||||
@@ -50,21 +51,22 @@ func GetUpgrader() *gws.Upgrader {
|
|||||||
return upgrader
|
return upgrader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWsConnection creates a new WebSocket connection wrapper.
|
// NewWsConnection creates a new WebSocket connection wrapper with agent version.
|
||||||
func NewWsConnection(conn *gws.Conn) *WsConn {
|
func NewWsConnection(conn *gws.Conn, agentVersion semver.Version) *WsConn {
|
||||||
return &WsConn{
|
return &WsConn{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
responseChan: make(chan *gws.Message, 1),
|
requestManager: NewRequestManager(conn),
|
||||||
DownChan: make(chan struct{}, 1),
|
DownChan: make(chan struct{}, 1),
|
||||||
|
agentVersion: agentVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnOpen sets a deadline for the WebSocket connection.
|
// OnOpen sets a deadline for the WebSocket connection and extracts agent version.
|
||||||
func (h *Handler) OnOpen(conn *gws.Conn) {
|
func (h *Handler) OnOpen(conn *gws.Conn) {
|
||||||
conn.SetDeadline(time.Now().Add(deadline))
|
conn.SetDeadline(time.Now().Add(deadline))
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnMessage routes incoming WebSocket messages to the response channel.
|
// OnMessage routes incoming WebSocket messages to the request manager.
|
||||||
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
|
func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
|
||||||
conn.SetDeadline(time.Now().Add(deadline))
|
conn.SetDeadline(time.Now().Add(deadline))
|
||||||
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
|
if message.Opcode != gws.OpcodeBinary || message.Data.Len() == 0 {
|
||||||
@@ -75,12 +77,7 @@ func (h *Handler) OnMessage(conn *gws.Conn, message *gws.Message) {
|
|||||||
_ = conn.WriteClose(1000, nil)
|
_ = conn.WriteClose(1000, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
wsConn.(*WsConn).requestManager.handleResponse(message)
|
||||||
case wsConn.(*WsConn).responseChan <- message:
|
|
||||||
default:
|
|
||||||
// close if the connection is not expecting a response
|
|
||||||
wsConn.(*WsConn).Close(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnClose handles WebSocket connection closures and triggers system down status after delay.
|
// OnClose handles WebSocket connection closures and triggers system down status after delay.
|
||||||
@@ -106,6 +103,9 @@ func (ws *WsConn) Close(msg []byte) {
|
|||||||
if ws.IsConnected() {
|
if ws.IsConnected() {
|
||||||
ws.conn.WriteClose(1000, msg)
|
ws.conn.WriteClose(1000, msg)
|
||||||
}
|
}
|
||||||
|
if ws.requestManager != nil {
|
||||||
|
ws.requestManager.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping sends a ping frame to keep the connection alive.
|
// Ping sends a ping frame to keep the connection alive.
|
||||||
@@ -115,6 +115,7 @@ func (ws *WsConn) Ping() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
// sendMessage encodes data to CBOR and sends it as a binary message to the agent.
|
||||||
|
// This is kept for backwards compatibility but new actions should use RequestManager.
|
||||||
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
||||||
if ws.conn == nil {
|
if ws.conn == nil {
|
||||||
return gws.ErrConnClosed
|
return gws.ErrConnClosed
|
||||||
@@ -126,54 +127,34 @@ func (ws *WsConn) sendMessage(data common.HubRequest[any]) error {
|
|||||||
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
|
return ws.conn.WriteMessage(gws.OpcodeBinary, bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequestSystemData requests system metrics from the agent and unmarshals the response.
|
// handleAgentRequest processes a request to the agent, handling both legacy and new formats.
|
||||||
func (ws *WsConn) RequestSystemData(data *system.CombinedData) error {
|
func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandler) error {
|
||||||
var message *gws.Message
|
// Wait for response
|
||||||
|
|
||||||
ws.sendMessage(common.HubRequest[any]{
|
|
||||||
Action: common.GetData,
|
|
||||||
})
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(10 * time.Second):
|
case message := <-req.ResponseCh:
|
||||||
ws.Close(nil)
|
defer message.Close()
|
||||||
return gws.ErrConnClosed
|
// Cancel request context to stop timeout watcher promptly
|
||||||
case message = <-ws.responseChan:
|
defer req.Cancel()
|
||||||
|
data := message.Data.Bytes()
|
||||||
|
|
||||||
|
// Legacy format - unmarshal directly
|
||||||
|
if ws.agentVersion.LT(beszel.MinVersionAgentResponse) {
|
||||||
|
return handler.HandleLegacy(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format with AgentResponse wrapper
|
||||||
|
var agentResponse common.AgentResponse
|
||||||
|
if err := cbor.Unmarshal(data, &agentResponse); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if agentResponse.Error != "" {
|
||||||
|
return errors.New(agentResponse.Error)
|
||||||
|
}
|
||||||
|
return handler.Handle(agentResponse)
|
||||||
|
|
||||||
|
case <-req.Context.Done():
|
||||||
|
return req.Context.Err()
|
||||||
}
|
}
|
||||||
defer message.Close()
|
|
||||||
return cbor.Unmarshal(message.Data.Bytes(), data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFingerprint authenticates with the agent using SSH signature and returns the agent's fingerprint.
|
|
||||||
func (ws *WsConn) GetFingerprint(token string, signer ssh.Signer, needSysInfo bool) (common.FingerprintResponse, error) {
|
|
||||||
var clientFingerprint common.FingerprintResponse
|
|
||||||
challenge := []byte(token)
|
|
||||||
|
|
||||||
signature, err := signer.Sign(nil, challenge)
|
|
||||||
if err != nil {
|
|
||||||
return clientFingerprint, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = ws.sendMessage(common.HubRequest[any]{
|
|
||||||
Action: common.CheckFingerprint,
|
|
||||||
Data: common.FingerprintRequest{
|
|
||||||
Signature: signature.Blob,
|
|
||||||
NeedSysInfo: needSysInfo,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return clientFingerprint, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var message *gws.Message
|
|
||||||
select {
|
|
||||||
case message = <-ws.responseChan:
|
|
||||||
case <-time.After(10 * time.Second):
|
|
||||||
return clientFingerprint, errors.New("request expired")
|
|
||||||
}
|
|
||||||
defer message.Close()
|
|
||||||
|
|
||||||
err = cbor.Unmarshal(message.Data.Bytes(), &clientFingerprint)
|
|
||||||
return clientFingerprint, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConnected returns true if the WebSocket connection is active.
|
// IsConnected returns true if the WebSocket connection is active.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -36,26 +37,25 @@ func TestGetUpgrader(t *testing.T) {
|
|||||||
// TestNewWsConnection tests WebSocket connection creation
|
// TestNewWsConnection tests WebSocket connection creation
|
||||||
func TestNewWsConnection(t *testing.T) {
|
func TestNewWsConnection(t *testing.T) {
|
||||||
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
// We can't easily mock gws.Conn, so we'll pass nil and test the structure
|
||||||
wsConn := NewWsConnection(nil)
|
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
|
||||||
|
|
||||||
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
assert.NotNil(t, wsConn, "WebSocket connection should not be nil")
|
||||||
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
assert.Nil(t, wsConn.conn, "Connection should be nil as passed")
|
||||||
assert.NotNil(t, wsConn.responseChan, "Response channel should be initialized")
|
assert.NotNil(t, wsConn.requestManager, "Request manager should be initialized")
|
||||||
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
assert.NotNil(t, wsConn.DownChan, "Down channel should be initialized")
|
||||||
assert.Equal(t, 1, cap(wsConn.responseChan), "Response channel should have capacity of 1")
|
|
||||||
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
assert.Equal(t, 1, cap(wsConn.DownChan), "Down channel should have capacity of 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWsConn_IsConnected tests the connection status check
|
// TestWsConn_IsConnected tests the connection status check
|
||||||
func TestWsConn_IsConnected(t *testing.T) {
|
func TestWsConn_IsConnected(t *testing.T) {
|
||||||
// Test with nil connection
|
// Test with nil connection
|
||||||
wsConn := NewWsConnection(nil)
|
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
|
||||||
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
assert.False(t, wsConn.IsConnected(), "Should not be connected when conn is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWsConn_Close tests the connection closing with nil connection
|
// TestWsConn_Close tests the connection closing with nil connection
|
||||||
func TestWsConn_Close(t *testing.T) {
|
func TestWsConn_Close(t *testing.T) {
|
||||||
wsConn := NewWsConnection(nil)
|
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
|
||||||
|
|
||||||
// Should handle nil connection gracefully
|
// Should handle nil connection gracefully
|
||||||
assert.NotPanics(t, func() {
|
assert.NotPanics(t, func() {
|
||||||
@@ -65,7 +65,7 @@ func TestWsConn_Close(t *testing.T) {
|
|||||||
|
|
||||||
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
// TestWsConn_SendMessage_CBOR tests CBOR encoding in sendMessage
|
||||||
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
func TestWsConn_SendMessage_CBOR(t *testing.T) {
|
||||||
wsConn := NewWsConnection(nil)
|
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
|
||||||
|
|
||||||
testData := common.HubRequest[any]{
|
testData := common.HubRequest[any]{
|
||||||
Action: common.GetData,
|
Action: common.GetData,
|
||||||
@@ -181,6 +181,17 @@ func TestCommonActions(t *testing.T) {
|
|||||||
// Test that the actions we use exist and have expected values
|
// Test that the actions we use exist and have expected values
|
||||||
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
assert.Equal(t, common.WebSocketAction(0), common.GetData, "GetData should be action 0")
|
||||||
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
assert.Equal(t, common.WebSocketAction(1), common.CheckFingerprint, "CheckFingerprint should be action 1")
|
||||||
|
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsHandler(t *testing.T) {
|
||||||
|
h := &stringResponseHandler{errorMsg: "no logs in response"}
|
||||||
|
|
||||||
|
logValue := "test logs"
|
||||||
|
resp := common.AgentResponse{String: &logValue}
|
||||||
|
err := h.Handle(resp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, logValue, h.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler tests that we can create a Handler
|
// TestHandler tests that we can create a Handler
|
||||||
@@ -194,7 +205,7 @@ func TestHandler(t *testing.T) {
|
|||||||
|
|
||||||
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
// TestWsConnChannelBehavior tests channel behavior without WebSocket connections
|
||||||
func TestWsConnChannelBehavior(t *testing.T) {
|
func TestWsConnChannelBehavior(t *testing.T) {
|
||||||
wsConn := NewWsConnection(nil)
|
wsConn := NewWsConnection(nil, semver.MustParse("0.12.10"))
|
||||||
|
|
||||||
// Test that channels are properly initialized and can be used
|
// Test that channels are properly initialized and can be used
|
||||||
select {
|
select {
|
||||||
@@ -212,11 +223,6 @@ func TestWsConnChannelBehavior(t *testing.T) {
|
|||||||
t.Error("Should be able to read from DownChan")
|
t.Error("Should be able to read from DownChan")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response channel should be empty initially
|
// Request manager should have no pending requests initially
|
||||||
select {
|
assert.Equal(t, 0, wsConn.requestManager.GetPendingCount(), "Should have no pending requests initially")
|
||||||
case <-wsConn.responseChan:
|
|
||||||
t.Error("Response channel should be empty initially")
|
|
||||||
default:
|
|
||||||
// Expected - channel should be empty
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
internal/hub/ws/ws_test_helpers.go
Normal file
11
internal/hub/ws/ws_test_helpers.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
// GetPendingCount returns the number of pending requests (for monitoring)
|
||||||
|
func (rm *RequestManager) GetPendingCount() int {
|
||||||
|
rm.RLock()
|
||||||
|
defer rm.RUnlock()
|
||||||
|
return len(rm.pendingReqs)
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
@@ -860,6 +859,152 @@ func init() {
|
|||||||
"system": false,
|
"system": false,
|
||||||
"authRule": "verified=true",
|
"authRule": "verified=true",
|
||||||
"manageRule": null
|
"manageRule": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pbc_1864144027",
|
||||||
|
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||||
|
"viewRule": null,
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"name": "containers",
|
||||||
|
"type": "base",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-f0-9]{6}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 12,
|
||||||
|
"min": 6,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-f0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "2hz5ncl8tizk5nx",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3377271179",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "system",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2063623452",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "status",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3470402323",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "health",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3128971310",
|
||||||
|
"max": 100,
|
||||||
|
"min": 0,
|
||||||
|
"name": "cpu",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3933025333",
|
||||||
|
"max": null,
|
||||||
|
"min": 0,
|
||||||
|
"name": "memory",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number4075427327",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "net",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3332085495",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "updated",
|
||||||
|
"onlyInt": true,
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3309110367",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "image",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_JxWirjdhyO` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `updated` + "`" + `)",
|
||||||
|
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
|
],
|
||||||
|
"system": false
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -868,31 +1013,6 @@ func init() {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all systems that don't have fingerprint records
|
|
||||||
var systemIds []string
|
|
||||||
err = app.DB().NewQuery(`
|
|
||||||
SELECT s.id FROM systems s
|
|
||||||
LEFT JOIN fingerprints f ON s.id = f.system
|
|
||||||
WHERE f.system IS NULL
|
|
||||||
`).Column(&systemIds)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Create fingerprint records with unique UUID tokens for each system
|
|
||||||
for _, systemId := range systemIds {
|
|
||||||
token := uuid.New().String()
|
|
||||||
_, err = app.DB().NewQuery(`
|
|
||||||
INSERT INTO fingerprints (system, token)
|
|
||||||
VALUES ({:system}, {:token})
|
|
||||||
`).Bind(map[string]any{
|
|
||||||
"system": systemId,
|
|
||||||
"token": token,
|
|
||||||
}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, func(app core.App) error {
|
}, func(app core.App) error {
|
||||||
return nil
|
return nil
|
||||||
@@ -213,6 +213,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.LoadAvg[2] += stats.LoadAvg[2]
|
sum.LoadAvg[2] += stats.LoadAvg[2]
|
||||||
sum.Bandwidth[0] += stats.Bandwidth[0]
|
sum.Bandwidth[0] += stats.Bandwidth[0]
|
||||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
|
sum.DiskIO[0] += stats.DiskIO[0]
|
||||||
|
sum.DiskIO[1] += stats.DiskIO[1]
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
// Set peak values
|
// Set peak values
|
||||||
@@ -224,6 +226,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
|
||||||
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
sum.MaxBandwidth[0] = max(sum.MaxBandwidth[0], stats.MaxBandwidth[0], stats.Bandwidth[0])
|
||||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||||
|
sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0])
|
||||||
|
sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1])
|
||||||
|
|
||||||
// Accumulate network interfaces
|
// Accumulate network interfaces
|
||||||
if sum.NetworkInterfaces == nil {
|
if sum.NetworkInterfaces == nil {
|
||||||
@@ -314,6 +318,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
|
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||||
|
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
@@ -431,6 +437,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = deleteOldContainerRecords(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -500,6 +510,20 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes container records that haven't been updated in the last 10 minutes
|
||||||
|
func deleteOldContainerRecords(app core.App) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||||
|
|
||||||
|
// Delete container records where updated < tenMinutesAgo
|
||||||
|
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
@@ -1,41 +1,83 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.2.3/schema.json",
|
||||||
"vcs": {
|
"vcs": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
"useIgnoreFile": false
|
"useIgnoreFile": true,
|
||||||
},
|
"defaultBranch": "main"
|
||||||
"files": {
|
|
||||||
"ignoreUnknown": false
|
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab",
|
"indentStyle": "tab",
|
||||||
"indentWidth": 2,
|
"lineWidth": 120,
|
||||||
"lineWidth": 120
|
"formatWithErrors": true
|
||||||
},
|
},
|
||||||
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"complexity": {
|
||||||
|
"noUselessStringConcat": "error",
|
||||||
|
"noUselessUndefinedInitialization": "error",
|
||||||
|
"noVoid": "error",
|
||||||
|
"useDateNow": "error"
|
||||||
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useUniqueElementIds": "off"
|
"noConstantMathMinMaxClamp": "error",
|
||||||
|
"noUndeclaredVariables": "error",
|
||||||
|
"noUnusedImports": "error",
|
||||||
|
"noUnusedFunctionParameters": "error",
|
||||||
|
"noUnusedPrivateClassMembers": "error",
|
||||||
|
"useExhaustiveDependencies": {
|
||||||
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"reportUnnecessaryDependencies": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"noUnusedVariables": "error"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noParameterProperties": "error",
|
||||||
|
"noYodaExpression": "error",
|
||||||
|
"useConsistentBuiltinInstantiation": "error",
|
||||||
|
"useFragmentSyntax": "error",
|
||||||
|
"useShorthandAssign": "error",
|
||||||
|
"useArrayLiterals": "error"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"useAwait": "error",
|
||||||
|
"noEvolvingTypes": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "double",
|
"quoteStyle": "double",
|
||||||
"semicolons": "asNeeded",
|
"trailingCommas": "es5",
|
||||||
"trailingCommas": "es5"
|
"semicolons": "asNeeded"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"assist": {
|
"overrides": [
|
||||||
"enabled": true,
|
{
|
||||||
"actions": {
|
"includes": ["**/*.jsx", "**/*.tsx"],
|
||||||
"source": {
|
"linter": {
|
||||||
"organizeImports": "on"
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noParameterAssign": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedVariables": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -3,8 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export default defineConfig({
|
|||||||
"hr",
|
"hr",
|
||||||
"hu",
|
"hu",
|
||||||
"it",
|
"it",
|
||||||
"is",
|
|
||||||
"ja",
|
"ja",
|
||||||
"ko",
|
"ko",
|
||||||
"nl",
|
"nl",
|
||||||
|
|||||||
761
internal/site/package-lock.json
generated
761
internal/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.11",
|
"version": "0.15.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -49,11 +49,12 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^0.42.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.3",
|
"@biomejs/biome": "2.2.4",
|
||||||
"@lingui/cli": "^5.4.1",
|
"@lingui/cli": "^5.4.1",
|
||||||
"@lingui/swc-plugin": "^5.6.1",
|
"@lingui/swc-plugin": "^5.6.1",
|
||||||
"@lingui/vite-plugin": "^5.4.1",
|
"@lingui/vite-plugin": "^5.4.1",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#22c55e"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 906 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#dc2626"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 906 B |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70" fill="#888"><path d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 903 B |
9
internal/site/public/static/icon.svg
Normal file
9
internal/site/public/static/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
|
<stop offset="0%" style="stop-color:#747bff"/>
|
||||||
|
<stop offset="100%" style="stop-color:#24eb5c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
85
internal/site/src/components/active-alerts.tsx
Normal file
85
internal/site/src/components/active-alerts.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { alertInfo } from "@/lib/alerts"
|
||||||
|
import { $alerts, $allSystemsById } from "@/lib/stores"
|
||||||
|
import type { AlertRecord } from "@/types"
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { $router, Link } from "./router"
|
||||||
|
import { Alert, AlertTitle, AlertDescription } from "./ui/alert"
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
|
||||||
|
|
||||||
|
export const ActiveAlerts = () => {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const systems = useStore($allSystemsById)
|
||||||
|
|
||||||
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
|
const activeAlerts: AlertRecord[] = []
|
||||||
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
|
const alertsKey: string[] = []
|
||||||
|
|
||||||
|
for (const systemId of Object.keys(alerts)) {
|
||||||
|
for (const alert of alerts[systemId].values()) {
|
||||||
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
|
activeAlerts.push(alert)
|
||||||
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeAlerts, alertsKey }
|
||||||
|
}, [alerts])
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
||||||
|
return useMemo(() => {
|
||||||
|
if (activeAlerts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle>
|
||||||
|
<Trans>Active Alerts</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="max-sm:p-2">
|
||||||
|
{activeAlerts.length > 0 && (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||||
|
{activeAlerts.map((alert) => {
|
||||||
|
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
||||||
|
>
|
||||||
|
<info.icon className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{alert.name === "Status" ? (
|
||||||
|
<Trans>Connection is down</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Exceeds {alert.value}
|
||||||
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { id: systems[alert.system]?.id })}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}, [alertsKey.join("")])
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
|
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ import { useStore } from "@nanostores/react"
|
|||||||
import { HistoryIcon } from "lucide-react"
|
import { HistoryIcon } from "lucide-react"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { $chartTime } from "@/lib/stores"
|
import { $chartTime } from "@/lib/stores"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn, compareSemVer, parseSemVer } from "@/lib/utils"
|
||||||
import type { ChartTimes } from "@/types"
|
import type { ChartTimes, SemVer } from "@/types"
|
||||||
|
import { memo } from "react"
|
||||||
|
|
||||||
export default function ChartTimeSelect({ className }: { className?: string }) {
|
export default memo(function ChartTimeSelect({
|
||||||
|
className,
|
||||||
|
agentVersion,
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
agentVersion: SemVer
|
||||||
|
}) {
|
||||||
const chartTime = useStore($chartTime)
|
const chartTime = useStore($chartTime)
|
||||||
|
|
||||||
|
// remove chart times that are not supported by the system agent version
|
||||||
|
const availableChartTimes = Object.entries(chartTimeData).filter(([_, { minVersion }]) => {
|
||||||
|
if (!minVersion) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return compareSemVer(agentVersion, parseSemVer(minVersion)) >= 0
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
|
<Select defaultValue="1h" value={chartTime} onValueChange={(value: ChartTimes) => $chartTime.set(value)}>
|
||||||
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
|
<SelectTrigger className={cn(className, "relative ps-10 pe-5")}>
|
||||||
@@ -15,7 +30,7 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(chartTimeData).map(([value, { label }]) => (
|
{availableChartTimes.map(([value, { label }]) => (
|
||||||
<SelectItem key={value} value={value}>
|
<SelectItem key={value} value={value}>
|
||||||
{label()}
|
{label()}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -23,4 +38,4 @@ export default function ChartTimeSelect({ className }: { className?: string }) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -94,8 +94,11 @@ export default memo(function ContainerChart({
|
|||||||
if (!filter) {
|
if (!filter) {
|
||||||
return new Set<string>()
|
return new Set<string>()
|
||||||
}
|
}
|
||||||
const filterLower = filter.toLowerCase()
|
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
|
||||||
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower)))
|
return new Set(Object.keys(chartConfig).filter((key) => {
|
||||||
|
const keyLower = key.toLowerCase()
|
||||||
|
return !filterTerms.some(term => keyLower.includes(term))
|
||||||
|
}))
|
||||||
}, [chartConfig, filter])
|
}, [chartConfig, filter])
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
// console.log('rendered at', new Date())
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
|||||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { Unit } from "@/lib/enums"
|
import { Unit } from "@/lib/enums"
|
||||||
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
|
||||||
export default memo(function DiskChart({
|
export default memo(function DiskChart({
|
||||||
@@ -12,7 +12,7 @@ export default memo(function DiskChart({
|
|||||||
diskSize,
|
diskSize,
|
||||||
chartData,
|
chartData,
|
||||||
}: {
|
}: {
|
||||||
dataKey: string
|
dataKey: string | ((data: SystemStatsRecord) => number | undefined)
|
||||||
diskSize: number
|
diskSize: number
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -9,46 +9,58 @@ import {
|
|||||||
xAxis,
|
xAxis,
|
||||||
} from "@/components/ui/chart"
|
} from "@/components/ui/chart"
|
||||||
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData, GPUData } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
import type { DataPoint } from "./line-chart"
|
||||||
|
|
||||||
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
const packageKey = " package"
|
||||||
|
|
||||||
|
const { gpuData, dataPoints } = useMemo(() => {
|
||||||
|
const dataPoints = [] as DataPoint[]
|
||||||
|
const gpuData = [] as Record<string, GPUData | string>[]
|
||||||
|
const addedKeys = new Map<string, number>()
|
||||||
|
|
||||||
|
const addKey = (key: string, value: number) => {
|
||||||
|
addedKeys.set(key, (addedKeys.get(key) ?? 0) + value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const stats of chartData.systemStats) {
|
||||||
|
const gpus = stats.stats?.g ?? {}
|
||||||
|
const data = { created: stats.created } as Record<string, GPUData | string>
|
||||||
|
for (const id in gpus) {
|
||||||
|
const gpu = gpus[id] as GPUData
|
||||||
|
data[gpu.n] = gpu
|
||||||
|
addKey(gpu.n, gpu.p ?? 0)
|
||||||
|
if (gpu.pp) {
|
||||||
|
data[`${gpu.n}${packageKey}`] = gpu
|
||||||
|
addKey(`${gpu.n}${packageKey}`, gpu.pp ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gpuData.push(data)
|
||||||
|
}
|
||||||
|
const sortedKeys = Array.from(addedKeys.entries())
|
||||||
|
.sort(([, a], [, b]) => b - a)
|
||||||
|
.map(([key]) => key)
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedKeys.length; i++) {
|
||||||
|
const id = sortedKeys[i]
|
||||||
|
dataPoints.push({
|
||||||
|
label: id,
|
||||||
|
dataKey: (gpuData: Record<string, GPUData>) => {
|
||||||
|
return id.endsWith(packageKey) ? (gpuData[id]?.pp ?? 0) : (gpuData[id]?.p ?? 0)
|
||||||
|
},
|
||||||
|
color: `hsl(${226 + (((i * 360) / addedKeys.size) % 360)}, 65%, 52%)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { gpuData, dataPoints }
|
||||||
|
}, [chartData])
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format temperature data for chart and assign colors */
|
|
||||||
const newChartData = useMemo(() => {
|
|
||||||
const newChartData = { data: [], colors: {} } as {
|
|
||||||
data: Record<string, number | string>[]
|
|
||||||
colors: Record<string, string>
|
|
||||||
}
|
|
||||||
const powerSums = {} as Record<string, number>
|
|
||||||
for (const data of chartData.systemStats) {
|
|
||||||
const newData = { created: data.created } as Record<string, number | string>
|
|
||||||
|
|
||||||
for (const gpu of Object.values(data.stats?.g ?? {})) {
|
|
||||||
if (gpu.p) {
|
|
||||||
const name = gpu.n
|
|
||||||
newData[name] = gpu.p
|
|
||||||
powerSums[name] = (powerSums[name] ?? 0) + newData[name]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newChartData.data.push(newData)
|
|
||||||
}
|
|
||||||
const keys = Object.keys(powerSums).sort((a, b) => powerSums[b] - powerSums[a])
|
|
||||||
for (const key of keys) {
|
|
||||||
newChartData.colors[key] = `hsl(${(226 + (keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
|
|
||||||
}
|
|
||||||
return newChartData
|
|
||||||
}, [chartData])
|
|
||||||
|
|
||||||
const colors = Object.keys(newChartData.colors)
|
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -56,7 +68,7 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
"opacity-100": yAxisWidth,
|
"opacity-100": yAxisWidth,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
|
<LineChart accessibilityLayer data={gpuData} margin={chartMargin}>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
@@ -85,19 +97,19 @@ export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{colors.map((key) => (
|
{dataPoints.map((dataPoint) => (
|
||||||
<Line
|
<Line
|
||||||
key={key}
|
key={dataPoint.label}
|
||||||
dataKey={key}
|
dataKey={dataPoint.dataKey}
|
||||||
name={key}
|
name={dataPoint.label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
dot={false}
|
dot={false}
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
stroke={newChartData.colors[key]}
|
stroke={dataPoint.color as string}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{colors.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
{dataPoints.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,8 +59,6 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
// @ts-expect-error
|
|
||||||
// itemSorter={(a, b) => b.value - a.value}
|
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
@@ -70,14 +68,15 @@ export default memo(function LoadAverageChart({ chartData }: { chartData: ChartD
|
|||||||
/>
|
/>
|
||||||
{keys.map(({ legacy, color, label }, i) => {
|
{keys.map(({ legacy, color, label }, i) => {
|
||||||
const dataKey = (value: { stats: SystemStats }) => {
|
const dataKey = (value: { stats: SystemStats }) => {
|
||||||
if (chartData.agentVersion.patch < 1) {
|
const { minor, patch } = chartData.agentVersion
|
||||||
|
if (minor <= 12 && patch < 1) {
|
||||||
return value.stats?.[legacy]
|
return value.stats?.[legacy]
|
||||||
}
|
}
|
||||||
return value.stats?.la?.[i] ?? value.stats?.[legacy]
|
return value.stats?.la?.[i] ?? value.stats?.[legacy]
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
key={i}
|
key={label}
|
||||||
dataKey={dataKey}
|
dataKey={dataKey}
|
||||||
name={label}
|
name={label}
|
||||||
type="monotoneX"
|
type="monotoneX"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
direction="ltr"
|
direction="ltr"
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
domain={[0, "auto"]}
|
domain={["auto", "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
@@ -91,7 +91,8 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{colors.map((key) => {
|
{colors.map((key) => {
|
||||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
|
||||||
|
const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term))
|
||||||
const strokeOpacity = filtered ? 0.1 : 1
|
const strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DialogDescription } from "@radix-ui/react-dialog"
|
|||||||
import {
|
import {
|
||||||
AlertOctagonIcon,
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -65,7 +66,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={system.id}
|
key={system.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "system", { name: system.name }))
|
navigate(getPagePath($router, "system", { id: system.id }))
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -80,7 +81,7 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home"]}
|
keywords={["home", t`All Systems`]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -94,6 +95,20 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(getPagePath($router, "containers"))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContainerIcon className="me-2 size-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>All Containers</Trans>
|
||||||
|
</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Page</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
||||||
|
import type { ContainerRecord } from "@/types"
|
||||||
|
import { ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
|
||||||
|
import {
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ClockIcon,
|
||||||
|
ContainerIcon,
|
||||||
|
CpuIcon,
|
||||||
|
LayersIcon,
|
||||||
|
MemoryStickIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { EthernetIcon, HourglassIcon } from "../ui/icons"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
|
||||||
|
// Unit names and their corresponding number of seconds for converting docker status strings
|
||||||
|
const unitSeconds = [["s", 1], ["mi", 60], ["h", 3600], ["d", 86400], ["w", 604800], ["mo", 2592000]] as const
|
||||||
|
// Convert docker status string to number of seconds ("Up X minutes", "Up X hours", etc.)
|
||||||
|
function getStatusValue(status: string): number {
|
||||||
|
const [_, num, unit] = status.split(" ")
|
||||||
|
const numValue = Number(num)
|
||||||
|
for (const [unitName, value] of unitSeconds) {
|
||||||
|
if (unit.startsWith(unitName)) {
|
||||||
|
return numValue * value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export const containerChartCols: ColumnDef<ContainerRecord>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
|
accessorFn: (record) => record.name,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={ContainerIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return <span className="ms-1.5 xl:w-48 block truncate">{getValue() as string}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
accessorFn: (record) => record.system,
|
||||||
|
sortingFn: (a, b) => {
|
||||||
|
const allSystems = $allSystemsById.get()
|
||||||
|
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||||
|
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||||
|
return systemNameA.localeCompare(systemNameB)
|
||||||
|
},
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const allSystems = useStore($allSystemsById)
|
||||||
|
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: "id",
|
||||||
|
// accessorFn: (record) => record.id,
|
||||||
|
// sortingFn: (a, b) => a.original.id.localeCompare(b.original.id),
|
||||||
|
// header: ({ column }) => <HeaderButton column={column} name="ID" Icon={HashIcon} />,
|
||||||
|
// cell: ({ getValue }) => {
|
||||||
|
// return <span className="ms-1.5 me-3 font-mono">{getValue() as string}</span>
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
id: "cpu",
|
||||||
|
accessorFn: (record) => record.cpu,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
accessorFn: (record) => record.memory,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
const formatted = formatBytes(val, false, undefined, true)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "net",
|
||||||
|
accessorFn: (record) => record.net,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
const formatted = formatBytes(val, true, undefined, true)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "health",
|
||||||
|
invertSorting: true,
|
||||||
|
accessorFn: (record) => record.health,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Health`} Icon={ShieldCheckIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const healthValue = getValue() as number
|
||||||
|
const healthStatus = ContainerHealthLabels[healthValue] || "Unknown"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="dark:border-white/12">
|
||||||
|
<span className={cn("size-2 me-1.5 rounded-full", {
|
||||||
|
"bg-green-500": healthValue === ContainerHealth.Healthy,
|
||||||
|
"bg-red-500": healthValue === ContainerHealth.Unhealthy,
|
||||||
|
"bg-yellow-500": healthValue === ContainerHealth.Starting,
|
||||||
|
"bg-zinc-500": healthValue === ContainerHealth.None,
|
||||||
|
})}>
|
||||||
|
</span>
|
||||||
|
{healthStatus}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "image",
|
||||||
|
sortingFn: (a, b) => a.original.image.localeCompare(b.original.image),
|
||||||
|
accessorFn: (record) => record.image,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Image", context: "Docker image" })} Icon={LayersIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return <span className="ms-1.5 xl:w-40 block truncate">{getValue() as string}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
accessorFn: (record) => record.status,
|
||||||
|
invertSorting: true,
|
||||||
|
sortingFn: (a, b) => getStatusValue(a.original.status) - getStatusValue(b.original.status),
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={HourglassIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return <span className="ms-1.5 w-25 block truncate">{getValue() as string}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
invertSorting: true,
|
||||||
|
accessorFn: (record) => record.updated,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const timestamp = getValue() as number
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">
|
||||||
|
{hourWithSeconds(new Date(timestamp).toISOString())}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function HeaderButton({ column, name, Icon }: { column: Column<ContainerRecord>; name: string; Icon: React.ElementType }) {
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{name}
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type Row,
|
||||||
|
type SortingState,
|
||||||
|
type Table as TableType,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
import { memo, RefObject, useEffect, useRef, useState } from "react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import type { ContainerRecord } from "@/types"
|
||||||
|
import { containerChartCols } from "@/components/containers-table/containers-table-columns"
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { type ContainerHealth, ContainerHealthLabels } from "@/lib/enums"
|
||||||
|
import { cn, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
|
import { MaximizeIcon, RefreshCwIcon } from "lucide-react"
|
||||||
|
import { Separator } from "../ui/separator"
|
||||||
|
import { $router, Link } from "../router"
|
||||||
|
import { listenKeys } from "nanostores"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
|
const syntaxTheme = "github-dark-dimmed"
|
||||||
|
|
||||||
|
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||||
|
const [data, setData] = useState<ContainerRecord[]>([])
|
||||||
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
|
`sort-c-${systemId ? 1 : 0}`,
|
||||||
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
|
sessionStorage
|
||||||
|
)
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pbOptions = {
|
||||||
|
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = (lastXMs: number) => {
|
||||||
|
const updated = Date.now() - lastXMs
|
||||||
|
let filter: string
|
||||||
|
if (systemId) {
|
||||||
|
filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated })
|
||||||
|
} else {
|
||||||
|
filter = pb.filter("updated > {:updated}", { updated })
|
||||||
|
}
|
||||||
|
pb.collection<ContainerRecord>("containers")
|
||||||
|
.getList(0, 2000, {
|
||||||
|
...pbOptions,
|
||||||
|
filter,
|
||||||
|
})
|
||||||
|
.then(({ items }) => setData((curItems) => {
|
||||||
|
const containerIds = new Set(items.map(item => item.id))
|
||||||
|
const now = Date.now()
|
||||||
|
for (const item of curItems) {
|
||||||
|
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
|
||||||
|
items.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial load
|
||||||
|
fetchData(70_000)
|
||||||
|
|
||||||
|
// if no systemId, poll every 10 seconds
|
||||||
|
if (!systemId) {
|
||||||
|
// poll every 10 seconds
|
||||||
|
const intervalId = setInterval(() => fetchData(10_500), 10_000)
|
||||||
|
// clear interval on unmount
|
||||||
|
return () => clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if systemId, fetch containers after the system is updated
|
||||||
|
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||||
|
const changeTime = Date.now()
|
||||||
|
setTimeout(() => fetchData(Date.now() - changeTime + 1000), 100)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
defaultColumn: {
|
||||||
|
sortUndefined: "last",
|
||||||
|
size: 100,
|
||||||
|
minSize: 0,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const container = row.original
|
||||||
|
const systemName = $allSystemsById.get()[container.system]?.name ?? ""
|
||||||
|
const id = container.id ?? ""
|
||||||
|
const name = container.name ?? ""
|
||||||
|
const status = container.status ?? ""
|
||||||
|
const healthLabel = ContainerHealthLabels[container.health as ContainerHealth] ?? ""
|
||||||
|
const image = container.image ?? ""
|
||||||
|
const searchString = `${systemName} ${id} ${name} ${healthLabel} ${status} ${image}`.toLowerCase()
|
||||||
|
|
||||||
|
return (filterValue as string)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.every((term) => searchString.includes(term))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = table.getRowModel().rows
|
||||||
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6 @container w-full">
|
||||||
|
<CardHeader className="p-0 mb-4">
|
||||||
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle className="mb-2">
|
||||||
|
<Trans>All Containers</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex">
|
||||||
|
<Trans>Click on a container to view more information.</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="rounded-md">
|
||||||
|
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AllContainersTable = memo(
|
||||||
|
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
|
||||||
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeContainer = useRef<ContainerRecord | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const openSheet = (container: ContainerRecord) => {
|
||||||
|
activeContainer.current = container
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
count: rows.length,
|
||||||
|
estimateSize: () => 54,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||||
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
{/* add header height to table size */}
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="text-sm w-full h-full text-nowrap">
|
||||||
|
<ContainersTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return (
|
||||||
|
<ContainerTableRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
virtualRow={virtualRow}
|
||||||
|
openSheet={openSheet}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
||||||
|
try {
|
||||||
|
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
||||||
|
system: container.system,
|
||||||
|
container: container.id,
|
||||||
|
})])
|
||||||
|
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
||||||
|
try {
|
||||||
|
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
|
||||||
|
system: container.system,
|
||||||
|
container: container.id,
|
||||||
|
})])
|
||||||
|
try {
|
||||||
|
info = JSON.stringify(JSON.parse(info), null, 2)
|
||||||
|
} catch (_) { }
|
||||||
|
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject<ContainerRecord | null> }) {
|
||||||
|
const container = activeContainer.current
|
||||||
|
if (!container) return null
|
||||||
|
|
||||||
|
const [logsDisplay, setLogsDisplay] = useState<string>("")
|
||||||
|
const [infoDisplay, setInfoDisplay] = useState<string>("")
|
||||||
|
const [logsFullscreenOpen, setLogsFullscreenOpen] = useState<boolean>(false)
|
||||||
|
const [infoFullscreenOpen, setInfoFullscreenOpen] = useState<boolean>(false)
|
||||||
|
const [isRefreshingLogs, setIsRefreshingLogs] = useState<boolean>(false)
|
||||||
|
const logsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
function scrollLogsToBottom() {
|
||||||
|
if (logsContainerRef.current) {
|
||||||
|
logsContainerRef.current.scrollTo({ top: logsContainerRef.current.scrollHeight })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshLogs = async () => {
|
||||||
|
setIsRefreshingLogs(true)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logsHtml = await getLogsHtml(container)
|
||||||
|
setLogsDisplay(logsHtml)
|
||||||
|
setTimeout(scrollLogsToBottom, 20)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
// Ensure minimum spin duration of 800ms
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
const remaining = Math.max(0, 500 - elapsed)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsRefreshingLogs(false)
|
||||||
|
}, remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogsDisplay("")
|
||||||
|
setInfoDisplay("");
|
||||||
|
if (!container) return
|
||||||
|
(async () => {
|
||||||
|
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||||
|
setLogsDisplay(logsHtml)
|
||||||
|
setInfoDisplay(infoHtml)
|
||||||
|
setTimeout(scrollLogsToBottom, 20)
|
||||||
|
})()
|
||||||
|
}, [container])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LogsFullscreenDialog
|
||||||
|
open={logsFullscreenOpen}
|
||||||
|
onOpenChange={setLogsFullscreenOpen}
|
||||||
|
logsDisplay={logsDisplay}
|
||||||
|
containerName={container.name}
|
||||||
|
onRefresh={refreshLogs}
|
||||||
|
isRefreshing={isRefreshingLogs}
|
||||||
|
/>
|
||||||
|
<InfoFullscreenDialog
|
||||||
|
open={infoFullscreenOpen}
|
||||||
|
onOpenChange={setInfoFullscreenOpen}
|
||||||
|
infoDisplay={infoDisplay}
|
||||||
|
containerName={container.name}
|
||||||
|
/>
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<SheetContent className="w-full sm:max-w-220 p-2">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{container.name}</SheetTitle>
|
||||||
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{container.status}
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{container.image}
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{container.id}
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{ContainerHealthLabels[container.health as ContainerHealth]}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="px-3 pb-3 -mt-4 flex flex-col gap-3 h-full items-start">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<h3>{t`Logs`}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={refreshLogs}
|
||||||
|
className="h-8 w-8 p-0 ms-auto"
|
||||||
|
disabled={isRefreshingLogs}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogsFullscreenOpen(true)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<MaximizeIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
<h3>{t`Detail`}</h3>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setInfoFullscreenOpen(true)}
|
||||||
|
className="h-8 w-8 p-0 ms-auto"
|
||||||
|
>
|
||||||
|
<MaximizeIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className={cn("grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !infoDisplay && "animate-pulse")}>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContainerTableRow = memo(
|
||||||
|
function ContainerTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
openSheet,
|
||||||
|
}: {
|
||||||
|
row: Row<ContainerRecord>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
openSheet: (container: ContainerRecord) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer transition-opacity"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="py-0"
|
||||||
|
style={{
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
|
||||||
|
const outerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && logsDisplay) {
|
||||||
|
// Scroll the outer container to bottom
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (outerContainerRef.current) {
|
||||||
|
outerContainerRef.current.scrollTop = outerContainerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTimeout(scrollToBottom, 50)
|
||||||
|
}
|
||||||
|
}, [open, logsDisplay])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||||
|
<DialogTitle className="sr-only">{containerName} logs</DialogTitle>
|
||||||
|
<div ref={outerContainerRef} className="h-full overflow-auto">
|
||||||
|
<div className="h-full w-full px-3 leading-relaxed rounded-md bg-gh-dark text-sm">
|
||||||
|
<div className="py-3" dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void onRefresh()
|
||||||
|
}}
|
||||||
|
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
||||||
|
disabled={isRefreshing}
|
||||||
|
title={t`Refresh`}
|
||||||
|
aria-label={t`Refresh`}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||||
|
<DialogTitle className="sr-only">{containerName} info</DialogTitle>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="h-full w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm leading-relaxed">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
internal/site/src/components/footer-repo-link.tsx
Normal file
26
internal/site/src/components/footer-repo-link.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { GithubIcon } from "lucide-react"
|
||||||
|
import { Separator } from "./ui/separator"
|
||||||
|
|
||||||
|
export function FooterRepoLink() {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
||||||
|
<a
|
||||||
|
href="https://github.com/henrygd/beszel"
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<GithubIcon className="h-3 w-3" /> GitHub
|
||||||
|
</a>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<a
|
||||||
|
href="https://github.com/henrygd/beszel/releases"
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-foreground duration-75"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Beszel {globalThis.BESZEL.HUB_VERSION}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ export function LangToggle() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant={"ghost"} size="icon" className="hidden 450:flex">
|
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
|
||||||
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
|
||||||
<span className="sr-only">Language</span>
|
<span className="sr-only">Language</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
|
import { useId } from "react"
|
||||||
|
|
||||||
|
const d = "M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
||||||
|
|
||||||
export function Logo({ className }: { className?: string }) {
|
export function Logo({ className }: { className?: string }) {
|
||||||
|
const id = useId()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Righteous
|
// Righteous font from Google Fonts
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 285 75" className={className}>
|
||||||
{/* <defs>
|
<defs>
|
||||||
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
<linearGradient id={id} x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
<stop offset="0%" style={{ stopColor: "#747bff" }} />
|
<stop offset="10%" style={{ stopColor: "#747bff" }} />
|
||||||
<stop offset="100%" style={{ stopColor: "#24eb5c" }} />
|
<stop offset="90%" style={{ stopColor: "#24eb5c" }} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs> */}
|
</defs>
|
||||||
<path
|
<path
|
||||||
// fill="url(#gradient)"
|
className="duration-250 group-hover:opacity-0 group-hover:ease-in ease-out"
|
||||||
d="M146.4 73.1h-30.5V59.8h30.5a3.2 3.2 0 0 0 2.3-1 3.2 3.2 0 0 0 1-2.3q0-.8-.3-1.3a1.5 1.5 0 0 0-.7-.6 4.7 4.7 0 0 0-1-.3l-1.3-.1h-13.9q-3.4 0-6.5-1.3-3-1.3-5.2-3.6a16.9 16.9 0 0 1-3.6-5.3 16.3 16.3 0 0 1-1.3-6.5 16.4 16.4 0 0 1 1.3-6.4q1.3-3.1 3.6-5.4 2.2-2.2 5.2-3.5a16.3 16.3 0 0 1 6.5-1.3h27v13.3h-27a3.2 3.2 0 0 0-2.3 1 3.2 3.2 0 0 0-1 2.3 3.3 3.3 0 0 0 1 2.4 3.3 3.3 0 0 0 1.2.8 3.2 3.2 0 0 0 1.1.2h13.9a18.1 18.1 0 0 1 6 1 17.3 17.3 0 0 1 .4.2q3 1.1 5.3 3.2a15.1 15.1 0 0 1 3.6 4.9 14.7 14.7 0 0 1 1.3 5.4 17.2 17.2 0 0 1 0 .9 16 16 0 0 1-1 5.8 15.4 15.4 0 0 1-.3.7 17.3 17.3 0 0 1-3.6 5.2 16.4 16.4 0 0 1-5.3 3.6 16.2 16.2 0 0 1-6.4 1.3Zm64.5-13.3v13.3h-43.6l22-39h-22V21h43.6l-22 39h22ZM35 73.1H0v-70h35q4.4 0 8.2 1.6a21.4 21.4 0 0 1 6.6 4.6q2.9 2.8 4.5 6.6 1.7 3.8 1.7 8.2a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5 1.4 1.6 2.4 3.5a18.3 18.3 0 0 1 1.5 4A17.4 17.4 0 0 1 56 51a15.3 15.3 0 0 1 0 1.1q0 4.3-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.5-3.8 1.7-8.2 1.7Zm76-43L86 60.4l1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.6-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8 26.7 26.7 0 0 1-5.5-8.3 30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8Zm152.3 0-25 30.2 1.5.3a16.7 16.7 0 0 0 1.6 0q2 0 3.8-.4 1.8-.6 3.4-1.6 1.5-1 2.8-2.4a12.8 12.8 0 0 0 2-3.2l9.8 9.8q-1.9 2.6-4.3 4.7a27 27 0 0 1-5.2 3.6 26.1 26.1 0 0 1-6 2.2 26.8 26.8 0 0 1-6.3.8 26.4 26.4 0 0 1-10.4-2 26.2 26.2 0 0 1-8.5-5.8A26.7 26.7 0 0 1 217 58a30.4 30.4 0 0 1-.2-.4q-2.1-5-2.1-11.1a31.9 31.9 0 0 1 .7-7 27 27 0 0 1 1.4-4.3 27 27 0 0 1 3.8-6.6 24.5 24.5 0 0 1 2-2.2 26 26 0 0 1 8.4-5.6 27 27 0 0 1 10.4-2 26.3 26.3 0 0 1 6.4.8 26.9 26.9 0 0 1 6 2.2q2.7 1.5 5.2 3.6 2.4 2.1 4.3 4.8ZM283.4 0v73.1H270V0h13.4ZM14 17v14.1h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 30 40 29a6.9 6.9 0 0 0 1.5-2.3q.5-1.3.5-2.7a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.5q-.6-1.2-1.5-2.2a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.5 7.9 7.9 0 0 0-.2 0H14Zm0 28.1v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.2Q39 58 40 57.1a7 7 0 0 0 1.5-2.3 6.9 6.9 0 0 0 .5-2.5 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 48 40 47a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm63.3 8.3 15.5-20.6a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.3.9 14.7 14.7 0 0 0-1 3.5 18.7 18.7 0 0 0 0 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 0 .1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Zm152.3 0L245 32.8a8 8 0 0 0-1.4-.4 7 7 0 0 0-.4 0 17.2 17.2 0 0 0-1.6-.1 19.2 19.2 0 0 0-.3 0 13.3 13.3 0 0 0-5.1 1q-2.5 1-4.2 2.8a13.1 13.1 0 0 0-2.5 3.6 15.5 15.5 0 0 0-.4.9 14.7 14.7 0 0 0-.8 3.5 18.7 18.7 0 0 0-.2 2.4 17.6 17.6 0 0 0 0 .7v.8a29.4 29.4 0 0 0 .1.1 19.2 19.2 0 0 0 .2 2 20.2 20.2 0 0 0 .4 1.6 18.6 18.6 0 0 0 0 .2 7.5 7.5 0 0 0 .4.9 6 6 0 0 0 .3.6Z"
|
d={d}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-0 duration-250 group-hover:opacity-100 ease-in-out"
|
||||||
|
fill={`url(#${id})`}
|
||||||
|
d={d}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import {
|
import {
|
||||||
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
@@ -39,7 +40,7 @@ export default function Navbar() {
|
|||||||
<Link
|
<Link
|
||||||
href={basePath}
|
href={basePath}
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
className="p-2 ps-0 me-3"
|
className="p-2 ps-0 me-3 group"
|
||||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||||
>
|
>
|
||||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||||
@@ -47,18 +48,25 @@ export default function Navbar() {
|
|||||||
<SearchButton />
|
<SearchButton />
|
||||||
|
|
||||||
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "containers")}
|
||||||
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
aria-label="Containers"
|
||||||
|
>
|
||||||
|
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "settings", { name: "general" })}
|
href={getPagePath($router, "settings", { name: "general" })}
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}
|
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
>
|
>
|
||||||
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
<SettingsIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
</Link>
|
</Link>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button aria-label="User Actions" className={cn("", buttonVariants({ variant: "ghost", size: "icon" }))}>
|
<button aria-label="User Actions" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
<UserIcon className="h-[1.2rem] w-[1.2rem]" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -112,7 +120,7 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AddSystemButton className="ms-2" />
|
<AddSystemButton className="ms-2 hidden 450:flex" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createRouter } from "@nanostores/router"
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
system: `/system/:name`,
|
containers: "/containers",
|
||||||
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
request_otp: `/request-otp`,
|
request_otp: `/request-otp`,
|
||||||
|
|||||||
26
internal/site/src/components/routes/containers.tsx
Normal file
26
internal/site/src/components/routes/containers.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
|
import { memo, useEffect, useMemo } from "react"
|
||||||
|
import ContainersTable from "@/components/containers-table/containers-table"
|
||||||
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
|
|
||||||
|
export default memo(() => {
|
||||||
|
const { t } = useLingui()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${t`All Containers`} / Beszel`
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ActiveAlerts />
|
||||||
|
<ContainersTable />
|
||||||
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,128 +1,28 @@
|
|||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
import { GithubIcon } from "lucide-react"
|
|
||||||
import { memo, Suspense, useEffect, useMemo } from "react"
|
import { memo, Suspense, useEffect, useMemo } from "react"
|
||||||
import { $router, Link } from "@/components/router"
|
|
||||||
import SystemsTable from "@/components/systems-table/systems-table"
|
import SystemsTable from "@/components/systems-table/systems-table"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { alertInfo } from "@/lib/alerts"
|
|
||||||
import { $alerts, $allSystemsById } from "@/lib/stores"
|
|
||||||
import type { AlertRecord } from "@/types"
|
|
||||||
|
|
||||||
export default memo(() => {
|
export default memo(() => {
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t`Dashboard`} / Beszel`
|
document.title = `${t`All Systems`} / Beszel`
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<ActiveAlerts />
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<ActiveAlerts />
|
||||||
<SystemsTable />
|
<Suspense>
|
||||||
</Suspense>
|
<SystemsTable />
|
||||||
|
</Suspense>
|
||||||
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
|
|
||||||
<a
|
|
||||||
href="https://github.com/henrygd/beszel"
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center gap-0.5 text-muted-foreground hover:text-foreground duration-75"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<GithubIcon className="h-3 w-3" /> GitHub
|
|
||||||
</a>
|
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
|
||||||
<a
|
|
||||||
href="https://github.com/henrygd/beszel/releases"
|
|
||||||
target="_blank"
|
|
||||||
className="text-muted-foreground hover:text-foreground duration-75"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Beszel {globalThis.BESZEL.HUB_VERSION}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ActiveAlerts = () => {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const systems = useStore($allSystemsById)
|
|
||||||
|
|
||||||
const { activeAlerts, alertsKey } = useMemo(() => {
|
|
||||||
const activeAlerts: AlertRecord[] = []
|
|
||||||
// key to prevent re-rendering if alerts change but active alerts didn't
|
|
||||||
const alertsKey: string[] = []
|
|
||||||
|
|
||||||
for (const systemId of Object.keys(alerts)) {
|
|
||||||
for (const alert of alerts[systemId].values()) {
|
|
||||||
if (alert.triggered && alert.name in alertInfo) {
|
|
||||||
activeAlerts.push(alert)
|
|
||||||
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeAlerts, alertsKey }
|
|
||||||
}, [alerts])
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: alertsKey is inclusive
|
|
||||||
return useMemo(() => {
|
|
||||||
if (activeAlerts.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
|
||||||
<div className="px-2 sm:px-1">
|
|
||||||
<CardTitle>
|
|
||||||
<Trans>Active Alerts</Trans>
|
|
||||||
</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="max-sm:p-2">
|
|
||||||
{activeAlerts.length > 0 && (
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
|
||||||
{activeAlerts.map((alert) => {
|
|
||||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
key={alert.id}
|
|
||||||
className="hover:-translate-y-px duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black/5"
|
|
||||||
>
|
|
||||||
<info.icon className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{systems[alert.system]?.name} {info.name().toLowerCase().replace("cpu", "CPU")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{alert.name === "Status" ? (
|
|
||||||
<Trans>Connection is down</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
Exceeds {alert.value}
|
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "system", { name: systems[alert.system]?.name })}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}, [alertsKey.join("")])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function ConfigYaml() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
<Alert className="my-4 border-destructive text-destructive w-auto table md:pe-6">
|
||||||
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
<AlertCircleIcon className="size-4.5 stroke-destructive" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Caution - potential data loss</Trans>
|
<Trans>Caution - potential data loss</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { subscribeKeys } from "nanostores"
|
import { subscribeKeys } from "nanostores"
|
||||||
import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import AreaChartDefault from "@/components/charts/area-chart"
|
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||||
import ContainerChart from "@/components/charts/container-chart"
|
import ContainerChart from "@/components/charts/container-chart"
|
||||||
import DiskChart from "@/components/charts/disk-chart"
|
import DiskChart from "@/components/charts/disk-chart"
|
||||||
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
import GpuPowerChart from "@/components/charts/gpu-power-chart"
|
||||||
@@ -27,6 +27,7 @@ import { getPbTimestamp, pb } from "@/lib/api"
|
|||||||
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
|
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import {
|
import {
|
||||||
|
$allSystemsById,
|
||||||
$allSystemsByName,
|
$allSystemsByName,
|
||||||
$chartTime,
|
$chartTime,
|
||||||
$containerFilter,
|
$containerFilter,
|
||||||
@@ -40,6 +41,7 @@ import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
|||||||
import {
|
import {
|
||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
|
compareSemVer,
|
||||||
debounce,
|
debounce,
|
||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -49,7 +51,16 @@ import {
|
|||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
useBrowserStorage,
|
useBrowserStorage,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import type { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
import type {
|
||||||
|
ChartData,
|
||||||
|
ChartTimes,
|
||||||
|
ContainerStatsRecord,
|
||||||
|
GPUData,
|
||||||
|
SystemInfo,
|
||||||
|
SystemRecord,
|
||||||
|
SystemStats,
|
||||||
|
SystemStatsRecord,
|
||||||
|
} from "@/types"
|
||||||
import ChartTimeSelect from "../charts/chart-time-select"
|
import ChartTimeSelect from "../charts/chart-time-select"
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import Spinner from "../spinner"
|
import Spinner from "../spinner"
|
||||||
@@ -63,6 +74,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/
|
|||||||
import NetworkSheet from "./system/network-sheet"
|
import NetworkSheet from "./system/network-sheet"
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
data: {
|
data: {
|
||||||
@@ -83,7 +96,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const buffer = chartTime === "1m" ? 400 : 20_000
|
||||||
|
const now = new Date(Date.now() + buffer)
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
const data = {
|
const data = {
|
||||||
@@ -95,25 +109,28 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// add empty values between records to make gaps if interval is too large
|
// add empty values between records to make gaps if interval is too large
|
||||||
function addEmptyValues<T extends SystemStatsRecord | ContainerStatsRecord>(
|
function addEmptyValues<T extends { created: string | number | null }>(
|
||||||
prevRecords: T[],
|
prevRecords: T[],
|
||||||
newRecords: T[],
|
newRecords: T[],
|
||||||
expectedInterval: number
|
expectedInterval: number
|
||||||
) {
|
): T[] {
|
||||||
const modifiedRecords: T[] = []
|
const modifiedRecords: T[] = []
|
||||||
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
|
let prevTime = (prevRecords.at(-1)?.created ?? 0) as number
|
||||||
for (let i = 0; i < newRecords.length; i++) {
|
for (let i = 0; i < newRecords.length; i++) {
|
||||||
const record = newRecords[i]
|
const record = newRecords[i]
|
||||||
record.created = new Date(record.created).getTime()
|
if (record.created !== null) {
|
||||||
if (prevTime) {
|
record.created = new Date(record.created).getTime()
|
||||||
|
}
|
||||||
|
if (prevTime && record.created !== null) {
|
||||||
const interval = record.created - prevTime
|
const interval = record.created - prevTime
|
||||||
// if interval is too large, add a null record
|
// if interval is too large, add a null record
|
||||||
if (interval > expectedInterval / 2 + expectedInterval) {
|
if (interval > expectedInterval / 2 + expectedInterval) {
|
||||||
// @ts-expect-error
|
modifiedRecords.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
|
||||||
modifiedRecords.push({ created: null, stats: null })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevTime = record.created
|
if (record.created !== null) {
|
||||||
|
prevTime = record.created
|
||||||
|
}
|
||||||
modifiedRecords.push(record)
|
modifiedRecords.push(record)
|
||||||
}
|
}
|
||||||
return modifiedRecords
|
return modifiedRecords
|
||||||
@@ -137,14 +154,14 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function dockerOrPodman(str: string, system: SystemRecord) {
|
function dockerOrPodman(str: string, system: SystemRecord): string {
|
||||||
if (system.info.p) {
|
if (system.info.p) {
|
||||||
return str.replace("docker", "podman").replace("Docker", "Podman")
|
return str.replace("docker", "podman").replace("Docker", "Podman")
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(function SystemDetail({ name }: { name: string }) {
|
export default memo(function SystemDetail({ id }: { id: string }) {
|
||||||
const direction = useStore($direction)
|
const direction = useStore($direction)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -154,17 +171,15 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
const [system, setSystem] = useState({} as SystemRecord)
|
const [system, setSystem] = useState({} as SystemRecord)
|
||||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||||
const netCardRef = useRef<HTMLDivElement>(null)
|
const temperatureChartRef = useRef<HTMLDivElement>(null)
|
||||||
const persistChartTime = useRef(false)
|
const persistChartTime = useRef(false)
|
||||||
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
|
|
||||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||||
const [chartLoading, setChartLoading] = useState(true)
|
const [chartLoading, setChartLoading] = useState(true)
|
||||||
const isLongerChart = chartTime !== "1h"
|
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
|
||||||
const userSettings = $userSettings.get()
|
const userSettings = $userSettings.get()
|
||||||
const chartWrapRef = useRef<HTMLDivElement>(null)
|
const chartWrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${name} / Beszel`
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!persistChartTime.current) {
|
if (!persistChartTime.current) {
|
||||||
$chartTime.set($userSettings.get().chartTime)
|
$chartTime.set($userSettings.get().chartTime)
|
||||||
@@ -172,18 +187,71 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
persistChartTime.current = false
|
persistChartTime.current = false
|
||||||
setSystemStats([])
|
setSystemStats([])
|
||||||
setContainerData([])
|
setContainerData([])
|
||||||
setContainerFilterBar(null)
|
|
||||||
$containerFilter.set("")
|
$containerFilter.set("")
|
||||||
}
|
}
|
||||||
}, [name])
|
}, [id])
|
||||||
|
|
||||||
// find matching system and update when it changes
|
// find matching system and update when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribeKeys($allSystemsByName, [name], (newSystems) => {
|
if (!systems.length) {
|
||||||
const sys = newSystems[name]
|
return
|
||||||
sys?.id && setSystem(sys)
|
}
|
||||||
|
// allow old system-name slug to work
|
||||||
|
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
|
||||||
|
return subscribeKeys(store, [id], (newSystems) => {
|
||||||
|
const sys = newSystems[id]
|
||||||
|
if (sys) {
|
||||||
|
setSystem(sys)
|
||||||
|
document.title = `${sys?.name} / Beszel`
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [name])
|
}, [id, systems.length])
|
||||||
|
|
||||||
|
// hide 1m chart time if system agent version is less than 0.13.0
|
||||||
|
useEffect(() => {
|
||||||
|
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
|
||||||
|
$chartTime.set("1h")
|
||||||
|
}
|
||||||
|
}, [system?.info?.v])
|
||||||
|
|
||||||
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
|
useEffect(() => {
|
||||||
|
let unsub = () => { }
|
||||||
|
if (!system.id || chartTime !== "1m") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
|
||||||
|
$chartTime.set("1h")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pb.realtime
|
||||||
|
.subscribe(
|
||||||
|
`rt_metrics`,
|
||||||
|
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
|
||||||
|
if (data.container?.length > 0) {
|
||||||
|
const newContainerData = makeContainerData([
|
||||||
|
{ created: Date.now(), stats: data.container } as unknown as ContainerStatsRecord,
|
||||||
|
])
|
||||||
|
setContainerData((prevData) => addEmptyValues(prevData, prevData.slice(-59).concat(newContainerData), 1000))
|
||||||
|
}
|
||||||
|
setSystemStats((prevStats) =>
|
||||||
|
addEmptyValues(
|
||||||
|
prevStats,
|
||||||
|
prevStats.slice(-59).concat({ created: Date.now(), stats: data.stats } as SystemStatsRecord),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ query: { system: system.id } }
|
||||||
|
)
|
||||||
|
.then((us) => {
|
||||||
|
unsub = us
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsub?.()
|
||||||
|
}
|
||||||
|
}, [chartTime, system.id])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
@@ -221,13 +289,13 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
containerData.push(containerStats)
|
containerData.push(containerStats)
|
||||||
}
|
}
|
||||||
setContainerData(containerData)
|
return containerData
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime) {
|
if (!system.id || !chartTime || chartTime === "1m") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// loading: true
|
// loading: true
|
||||||
@@ -261,12 +329,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
}
|
}
|
||||||
cache.set(cs_cache_key, containerData)
|
cache.set(cs_cache_key, containerData)
|
||||||
}
|
}
|
||||||
if (containerData.length) {
|
setContainerData(makeContainerData(containerData))
|
||||||
!containerFilterBar && setContainerFilterBar(<FilterBar />)
|
|
||||||
} else if (containerFilterBar) {
|
|
||||||
setContainerFilterBar(null)
|
|
||||||
}
|
|
||||||
makeContainerData(containerData)
|
|
||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [system, chartTime])
|
||||||
|
|
||||||
@@ -335,19 +398,20 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
}[]
|
}[]
|
||||||
}, [system, t])
|
}, [system, t])
|
||||||
|
|
||||||
/** Space for tooltip if more than 12 containers */
|
/** Space for tooltip if more than 10 sensors and no containers table */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!netCardRef.current || !containerData.length) {
|
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
||||||
|
if (!temperatureChartRef.current || sensors.length < 10 || containerData.length > 0) {
|
||||||
setBottomSpacing(0)
|
setBottomSpacing(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const tooltipHeight = (Object.keys(containerData[0]).length - 11) * 17.8 - 40
|
const tooltipHeight = (sensors.length - 10) * 17.8 - 40
|
||||||
const wrapperEl = chartWrapRef.current as HTMLDivElement
|
const wrapperEl = chartWrapRef.current as HTMLDivElement
|
||||||
const wrapperRect = wrapperEl.getBoundingClientRect()
|
const wrapperRect = wrapperEl.getBoundingClientRect()
|
||||||
const chartRect = netCardRef.current.getBoundingClientRect()
|
const chartRect = temperatureChartRef.current.getBoundingClientRect()
|
||||||
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
|
||||||
setBottomSpacing(tooltipHeight - distanceToBottom)
|
setBottomSpacing(tooltipHeight - distanceToBottom)
|
||||||
}, [containerData])
|
}, [])
|
||||||
|
|
||||||
// keyboard navigation between systems
|
// keyboard navigation between systems
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -364,7 +428,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const currentIndex = systems.findIndex((s) => s.name === name)
|
const currentIndex = systems.findIndex((s) => s.id === id)
|
||||||
if (currentIndex === -1 || systems.length <= 1) {
|
if (currentIndex === -1 || systems.length <= 1) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -373,18 +437,18 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
case "h": {
|
case "h": {
|
||||||
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
|
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
|
||||||
persistChartTime.current = true
|
persistChartTime.current = true
|
||||||
return navigate(getPagePath($router, "system", { name: systems[prevIndex].name }))
|
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
|
||||||
}
|
}
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
case "l": {
|
case "l": {
|
||||||
const nextIndex = (currentIndex + 1) % systems.length
|
const nextIndex = (currentIndex + 1) % systems.length
|
||||||
persistChartTime.current = true
|
persistChartTime.current = true
|
||||||
return navigate(getPagePath($router, "system", { name: systems[nextIndex].name }))
|
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return listen(document, "keyup", handleKeyUp)
|
return listen(document, "keyup", handleKeyUp)
|
||||||
}, [name, systems])
|
}, [id, systems])
|
||||||
|
|
||||||
if (!system.id) {
|
if (!system.id) {
|
||||||
return null
|
return null
|
||||||
@@ -392,13 +456,14 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
// select field for switching between avg and max values
|
// select field for switching between avg and max values
|
||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
const showMax = chartTime !== "1h" && maxValues
|
const showMax = maxValues && isLongerChart
|
||||||
|
|
||||||
|
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
||||||
|
|
||||||
// if no data, show empty message
|
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
||||||
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
||||||
|
|
||||||
let translatedStatus: string = system.status
|
let translatedStatus: string = system.status
|
||||||
@@ -483,7 +548,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
|
||||||
<ChartTimeSelect className="w-full xl:w-40" />
|
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
|
||||||
<TooltipProvider delayDuration={100}>
|
<TooltipProvider delayDuration={100}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -508,6 +573,18 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
{/* <Tabs defaultValue="overview" className="w-full">
|
||||||
|
<TabsList className="w-full h-11">
|
||||||
|
<TabsTrigger value="overview" className="w-full h-9">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="containers" className="w-full h-9">Containers</TabsTrigger>
|
||||||
|
<TabsTrigger value="smart" className="w-full h-9">S.M.A.R.T.</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="smart">
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs> */}
|
||||||
|
|
||||||
|
|
||||||
{/* main charts */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -594,23 +671,33 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t({ message: "Write", comment: "Disk write" }),
|
label: t({ message: "Write", comment: "Disk write" }),
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.dwm : stats?.dw),
|
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||||
|
if (showMax) {
|
||||||
|
return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024
|
||||||
|
},
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t({ message: "Read", comment: "Disk read" }),
|
label: t({ message: "Read", comment: "Disk read" }),
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => (showMax ? stats?.drm : stats?.dr),
|
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||||
|
if (showMax) {
|
||||||
|
return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024
|
||||||
|
},
|
||||||
color: 1,
|
color: 1,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
}}
|
}}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -670,26 +757,20 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
{containerFilterBar && containerData.length > 0 && (
|
{containerFilterBar && containerData.length > 0 && (
|
||||||
<div
|
<ChartCard
|
||||||
ref={netCardRef}
|
empty={dataEmpty}
|
||||||
className={cn({
|
grid={grid}
|
||||||
"col-span-full": !grid,
|
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
||||||
})}
|
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
||||||
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ChartCard
|
<ContainerChart
|
||||||
empty={dataEmpty}
|
chartData={chartData}
|
||||||
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
chartType={ChartType.Network}
|
||||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
dataKey="n"
|
||||||
cornerEl={containerFilterBar}
|
chartConfig={containerChartConfigs.network}
|
||||||
>
|
/>
|
||||||
<ContainerChart
|
</ChartCard>
|
||||||
chartData={chartData}
|
|
||||||
chartType={ChartType.Network}
|
|
||||||
dataKey="n"
|
|
||||||
chartConfig={containerChartConfigs.network}
|
|
||||||
/>
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Swap chart */}
|
{/* Swap chart */}
|
||||||
@@ -719,16 +800,21 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
|
|
||||||
{/* Temperature chart */}
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<ChartCard
|
<div
|
||||||
empty={dataEmpty}
|
ref={temperatureChartRef}
|
||||||
grid={grid}
|
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
|
||||||
title={t`Temperature`}
|
|
||||||
description={t`Temperatures of system sensors`}
|
|
||||||
cornerEl={<FilterBar store={$temperatureFilter} />}
|
|
||||||
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
|
|
||||||
>
|
>
|
||||||
<TemperatureChart chartData={chartData} />
|
<ChartCard
|
||||||
</ChartCard>
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`Temperature`}
|
||||||
|
description={t`Temperatures of system sensors`}
|
||||||
|
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||||
|
legend={Object.keys(systemStats.at(-1)?.stats.t ?? {}).length < 12}
|
||||||
|
>
|
||||||
|
<TemperatureChart chartData={chartData} />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Battery chart */}
|
{/* Battery chart */}
|
||||||
@@ -791,7 +877,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
return (
|
return (
|
||||||
<div key={id} className="contents">
|
<div key={id} className="contents">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
className="!col-span-1"
|
className={cn(grid && "!col-span-1")}
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={`${gpu.n} ${t`Usage`}`}
|
title={`${gpu.n} ${t`Usage`}`}
|
||||||
@@ -861,7 +947,7 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
>
|
>
|
||||||
<DiskChart
|
<DiskChart
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
dataKey={`stats.efs.${extraFsName}.du`}
|
dataKey={({ stats }: SystemStatsRecord) => stats?.efs?.[extraFsName]?.du}
|
||||||
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
|
diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -877,24 +963,36 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
dataPoints={[
|
dataPoints={[
|
||||||
{
|
{
|
||||||
label: t`Write`,
|
label: t`Write`,
|
||||||
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "wm" : "w"] ?? 0,
|
dataKey: ({ stats }) => {
|
||||||
|
if (showMax) {
|
||||||
|
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
||||||
|
}
|
||||||
|
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
||||||
|
},
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t`Read`,
|
label: t`Read`,
|
||||||
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.[showMax ? "rm" : "r"] ?? 0,
|
dataKey: ({ stats }) => {
|
||||||
|
if (showMax) {
|
||||||
|
return (
|
||||||
|
stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024
|
||||||
|
},
|
||||||
color: 1,
|
color: 1,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
maxToggled={maxValues}
|
maxToggled={maxValues}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, true)
|
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
}}
|
}}
|
||||||
contentFormatter={({ value }) => {
|
contentFormatter={({ value }) => {
|
||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, true)
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -904,16 +1002,24 @@ export default memo(function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
|
||||||
|
<LazySmartTable systemId={system.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
||||||
|
<LazyContainersTable systemId={id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
{/* add space for tooltip if lots of sensors */}
|
||||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
||||||
const dataPoints = []
|
const dataPoints: DataPoint[] = []
|
||||||
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
|
const engines = Object.keys(chartData.systemStats?.at(-1)?.stats.g?.[0]?.e ?? {}).sort()
|
||||||
for (const engine of engines) {
|
for (const engine of engines) {
|
||||||
dataPoints.push({
|
dataPoints.push({
|
||||||
@@ -1033,3 +1139,25 @@ export function ChartCard({
|
|||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ContainersTable = lazy(() => import("../containers-table/containers-table"))
|
||||||
|
|
||||||
|
function LazyContainersTable({ systemId }: { systemId: string }) {
|
||||||
|
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
|
{isIntersecting && <ContainersTable systemId={systemId} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SmartTable = lazy(() => import("./system/smart-table"))
|
||||||
|
|
||||||
|
function LazySmartTable({ systemId }: { systemId: string }) {
|
||||||
|
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
|
{isIntersecting && <SmartTable systemId={systemId} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{hasOpened.current && (
|
{hasOpened.current && (
|
||||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||||
<ChartTimeSelect className="w-[calc(100%-2em)]" />
|
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
|
|||||||
490
internal/site/src/components/routes/system/smart-table.tsx
Normal file
490
internal/site/src/components/routes/system/smart-table.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react"
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { SmartData, SmartAttribute } from "@/types"
|
||||||
|
import { formatBytes, toFixedFloat, formatTemperature } from "@/lib/utils"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { ThermometerIcon } from "@/components/ui/icons"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
||||||
|
// Column definition for S.M.A.R.T. attributes table
|
||||||
|
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.n,
|
||||||
|
header: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorFn: (row) => row.rs || row.rv?.toString(),
|
||||||
|
header: "Value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "v",
|
||||||
|
header: "Normalized",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "w",
|
||||||
|
header: "Worst",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "t",
|
||||||
|
header: "Threshold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// accessorFn: (row) => row.wf,
|
||||||
|
accessorKey: "wf",
|
||||||
|
header: "Failing",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type DiskInfo = {
|
||||||
|
device: string
|
||||||
|
model: string
|
||||||
|
serialNumber: string
|
||||||
|
firmwareVersion: string
|
||||||
|
capacity: string
|
||||||
|
status: string
|
||||||
|
temperature: number
|
||||||
|
deviceType: string
|
||||||
|
powerOnHours?: number
|
||||||
|
powerCycles?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to format capacity display
|
||||||
|
function formatCapacity(bytes: number): string {
|
||||||
|
const { value, unit } = formatBytes(bytes)
|
||||||
|
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to convert SmartData to DiskInfo
|
||||||
|
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
|
||||||
|
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
|
||||||
|
device: smartData.dn || key,
|
||||||
|
model: smartData.mn || "Unknown",
|
||||||
|
serialNumber: smartData.sn || "Unknown",
|
||||||
|
firmwareVersion: smartData.fv || "Unknown",
|
||||||
|
capacity: smartData.c ? formatCapacity(smartData.c) : "Unknown",
|
||||||
|
status: smartData.s || "Unknown",
|
||||||
|
temperature: smartData.t || 0,
|
||||||
|
deviceType: smartData.dt || "Unknown",
|
||||||
|
// These fields need to be extracted from SmartAttribute if available
|
||||||
|
powerOnHours: smartData.a?.find(attr => attr.n.toLowerCase().includes("poweronhours") || attr.n.toLowerCase().includes("power_on_hours"))?.rv,
|
||||||
|
powerCycles: smartData.a?.find(attr => attr.n.toLowerCase().includes("power") && attr.n.toLowerCase().includes("cycle"))?.rv,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const columns: ColumnDef<DiskInfo>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "device",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<HardDrive className="size-4" />
|
||||||
|
<Trans>Device</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("device")}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "model",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Box className="size-4" />
|
||||||
|
<Trans>Model</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-50 truncate" title={row.getValue("model")}>
|
||||||
|
{row.getValue("model")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "capacity",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<BinaryIcon className="size-4" />
|
||||||
|
<Trans>Capacity</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "temperature",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThermometerIcon className="size-4" />
|
||||||
|
<Trans>Temp</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const { value, unit } = formatTemperature(getValue() as number)
|
||||||
|
return `${value} ${unit}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="size-4" />
|
||||||
|
<Trans>Status</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const status = getValue() as string
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "deviceType",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ArrowLeftRightIcon className="size-4" />
|
||||||
|
<Trans>Type</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Badge variant="outline" className="uppercase">
|
||||||
|
{getValue() as string}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "powerOnHours",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="size-4" />
|
||||||
|
<Trans comment="Power On Time">Power On</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hours = row.getValue("powerOnHours") as number | undefined
|
||||||
|
if (!hours && hours !== 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
N/A
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{hours.toLocaleString()} hours</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{days} days</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "powerCycles",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<RotateCwIcon className="size-4" />
|
||||||
|
<Trans comment="Power Cycles">Cycles</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const cycles = getValue() as number | undefined
|
||||||
|
if (!cycles && cycles !== 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
N/A
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cycles
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "serialNumber",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<HashIcon className="size-4" />
|
||||||
|
<Trans>Serial Number</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "firmwareVersion",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CpuIcon className="size-4" />
|
||||||
|
<Trans>Firmware</Trans>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DisksTable({ systemId }: { systemId: string }) {
|
||||||
|
// const [sorting, setSorting] = React.useState<SortingState>([{ id: "device", desc: false }])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({})
|
||||||
|
const [smartData, setSmartData] = React.useState<Record<string, SmartData> | undefined>(undefined)
|
||||||
|
const [activeDisk, setActiveDisk] = React.useState<DiskInfo | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const openSheet = (disk: DiskInfo) => {
|
||||||
|
setActiveDisk(disk)
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch smart data when component mounts or systemId changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (systemId) {
|
||||||
|
pb.send<Record<string, SmartData>>("/api/beszel/smart", { query: { system: systemId } })
|
||||||
|
.then((data) => {
|
||||||
|
setSmartData(data)
|
||||||
|
})
|
||||||
|
.catch(() => setSmartData({}))
|
||||||
|
}
|
||||||
|
}, [systemId])
|
||||||
|
|
||||||
|
// Convert SmartData to DiskInfo, if no data use empty array
|
||||||
|
const diskData = React.useMemo(() => {
|
||||||
|
return smartData ? convertSmartDataToDiskInfo(smartData) : []
|
||||||
|
}, [smartData])
|
||||||
|
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: diskData,
|
||||||
|
columns: columns,
|
||||||
|
// onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: {
|
||||||
|
// sorting,
|
||||||
|
columnFilters,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!diskData.length && !columnFilters.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card className="p-6 @container w-full">
|
||||||
|
<CardHeader className="p-0 mb-4">
|
||||||
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle className="mb-2">
|
||||||
|
S.M.A.R.T.
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex">
|
||||||
|
<Trans>Click on a device to view more information.</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
table.getColumn("device")?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="rounded-md border text-nowrap">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{smartData ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
|
||||||
|
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<DiskSheet disk={activeDisk} smartData={activeDisk && smartData ? Object.values(smartData).find(sd => sd.dn === activeDisk.device || sd.mn === activeDisk.model) : undefined} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
if (!disk) return null
|
||||||
|
|
||||||
|
const smartAttributes = smartData?.a || []
|
||||||
|
|
||||||
|
// Find all attributes where when failed is not empty
|
||||||
|
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '')
|
||||||
|
|
||||||
|
// Filter columns to only show those that have values in at least one row
|
||||||
|
const visibleColumns = React.useMemo(() => {
|
||||||
|
return smartColumns.filter(column => {
|
||||||
|
const accessorKey = (column as any).accessorKey as keyof SmartAttribute
|
||||||
|
if (!accessorKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Check if any row has a non-empty value for this column
|
||||||
|
return smartAttributes.some(attr => {
|
||||||
|
return attr[accessorKey] !== undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [smartAttributes])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: smartAttributes,
|
||||||
|
columns: visibleColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="w-full sm:max-w-220 gap-0">
|
||||||
|
<SheetHeader className="mb-0 border-b">
|
||||||
|
<SheetTitle><Trans>S.M.A.R.T. Details</Trans> - {disk.device}</SheetTitle>
|
||||||
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
{disk.model} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{disk.serialNumber}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
|
||||||
|
<Alert className="pb-3">
|
||||||
|
{smartData?.s === "PASSED" ? (
|
||||||
|
<CheckCircle2Icon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<AlertTitle><Trans>S.M.A.R.T. Self-Test</Trans>: {smartData?.s}</AlertTitle>
|
||||||
|
{failedAttributes.length > 0 && (
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>Failed Attributes:</Trans> {failedAttributes.map(attr => attr.n).join(", ")}
|
||||||
|
</AlertDescription>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
{smartAttributes.length > 0 ? (
|
||||||
|
<div className="rounded-md border overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
// Check if the attribute is failed
|
||||||
|
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
id: "system",
|
id: "system",
|
||||||
name: () => t`System`,
|
name: () => t`System`,
|
||||||
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
filterFn: (() => {
|
filterFn: (() => {
|
||||||
let filterInput = ""
|
let filterInput = ""
|
||||||
let filterInputLower = ""
|
let filterInputLower = ""
|
||||||
@@ -110,7 +111,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
invertSorting: false,
|
invertSorting: false,
|
||||||
Icon: ServerIcon,
|
Icon: ServerIcon,
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
const { name } = info.row.original
|
const { name, id } = info.row.original
|
||||||
const longestName = useStore($longestSystemNameLen)
|
const longestName = useStore($longestSystemNameLen)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -122,7 +123,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name })}
|
href={getPagePath($router, "system", { id })}
|
||||||
className="inset-0 absolute size-full"
|
className="inset-0 absolute size-full"
|
||||||
aria-label={name}
|
aria-label={name}
|
||||||
></Link>
|
></Link>
|
||||||
@@ -279,7 +280,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name: system.name })}
|
href={getPagePath($router, "system", { id: system.id })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10",
|
"flex gap-1.5 items-center md:pe-5 tabular-nums relative z-10",
|
||||||
viewMode === "table" && "ps-0.5"
|
viewMode === "table" && "ps-0.5"
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ export default function SystemsTable() {
|
|||||||
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
|
return [Object.values(upSystems).length, Object.values(downSystems).length, Object.values(pausedSystems).length]
|
||||||
}, [upSystems, downSystems, pausedSystems])
|
}, [upSystems, downSystems, pausedSystems])
|
||||||
|
|
||||||
// TODO: hiding temp then gpu messes up table headers
|
|
||||||
const CardHead = useMemo(() => {
|
const CardHead = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
@@ -487,7 +486,7 @@ const SystemCard = memo(
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name: row.original.name })}
|
href={getPagePath($router, "system", { id: row.original.id })}
|
||||||
className="inset-0 absolute w-full h-full"
|
className="inset-0 absolute w-full h-full"
|
||||||
>
|
>
|
||||||
<span className="sr-only">{row.original.name}</span>
|
<span className="sr-only">{row.original.name}</span>
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const badgeVariants = cva(
|
|||||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
|
success: "border-transparent bg-green-200 text-green-800",
|
||||||
|
danger: "border-transparent bg-red-200 text-red-800",
|
||||||
|
warning: "border-transparent bg-yellow-200 text-yellow-800",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -20,7 +23,7 @@ const badgeVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> { }
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
indicator?: "line" | "dot" | "dashed"
|
indicator?: "line" | "dot" | "dashed"
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
labelKey?: string
|
labelKey?: string
|
||||||
unit?: string
|
unit?: string
|
||||||
filter?: string
|
filter?: string
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -129,7 +129,11 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
|
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
|
||||||
|
payload = payload?.filter((item) => {
|
||||||
|
const itemName = (item.name as string)?.toLowerCase()
|
||||||
|
return filterTerms.some(term => itemName?.includes(term))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (itemSorter) {
|
if (itemSorter) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -250,10 +254,10 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
|
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@
|
|||||||
--color-green-900: hsl(140 54% 12%);
|
--color-green-900: hsl(140 54% 12%);
|
||||||
--color-green-950: hsl(140 57% 6%);
|
--color-green-950: hsl(140 57% 6%);
|
||||||
|
|
||||||
|
--color-gh-dark: #22272e;
|
||||||
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -110,12 +112,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, InterVariable, sans-serif;
|
font-family: Inter, InterVariable, sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: InterVariable;
|
font-family: InterVariable;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -130,16 +134,18 @@
|
|||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
overflow-anchor: none;
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility container {
|
@utility container {
|
||||||
@apply max-w-360 mx-auto px-4;
|
@apply max-w-370 mx-auto px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility link {
|
@utility link {
|
||||||
@@ -149,13 +155,14 @@
|
|||||||
@utility ns-dialog {
|
@utility ns-dialog {
|
||||||
/* New system dialog width */
|
/* New system dialog width */
|
||||||
min-width: 30.3rem;
|
min-width: 30.3rem;
|
||||||
|
|
||||||
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
||||||
min-width: 27.9rem;
|
min-width: 27.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
z-index: 1;
|
z-index: 51;
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import PocketBase from "pocketbase"
|
|||||||
import { basePath } from "@/components/router"
|
import { basePath } from "@/components/router"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import type { ChartTimes, UserSettings } from "@/types"
|
import type { ChartTimes, UserSettings } from "@/types"
|
||||||
import { $alerts, $allSystemsByName, $userSettings } from "./stores"
|
import { $alerts, $allSystemsById, $allSystemsByName, $userSettings } from "./stores"
|
||||||
import { chartTimeData } from "./utils"
|
import { chartTimeData } from "./utils"
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
@@ -26,8 +26,9 @@ export const verifyAuth = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||||
export async function logOut() {
|
export function logOut() {
|
||||||
$allSystemsByName.set({})
|
$allSystemsByName.set({})
|
||||||
|
$allSystemsById.set({})
|
||||||
$alerts.set({})
|
$alerts.set({})
|
||||||
$userSettings.set({} as UserSettings)
|
$userSettings.set({} as UserSettings)
|
||||||
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export enum HourFormat {
|
|||||||
"24h" = "24h",
|
"24h" = "24h",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Container health status */
|
||||||
|
export enum ContainerHealth {
|
||||||
|
None,
|
||||||
|
Starting,
|
||||||
|
Healthy,
|
||||||
|
Unhealthy,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContainerHealthLabels = ["None", "Starting", "Healthy", "Unhealthy"] as const
|
||||||
|
|
||||||
/** Connection type */
|
/** Connection type */
|
||||||
export enum ConnectionType {
|
export enum ConnectionType {
|
||||||
SSH = 1,
|
SSH = 1,
|
||||||
|
|||||||
28
internal/site/src/lib/shiki.ts
Normal file
28
internal/site/src/lib/shiki.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// https://shiki.style/guide/bundles#fine-grained-bundle
|
||||||
|
|
||||||
|
// directly import the theme and language modules, only the ones you imported will be bundled.
|
||||||
|
import githubDarkDimmed from '@shikijs/themes/github-dark-dimmed'
|
||||||
|
|
||||||
|
// `shiki/core` entry does not include any themes or languages or the wasm binary.
|
||||||
|
import { createHighlighterCore } from 'shiki/core'
|
||||||
|
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
|
||||||
|
|
||||||
|
export const highlighter = await createHighlighterCore({
|
||||||
|
themes: [
|
||||||
|
// instead of strings, you need to pass the imported module
|
||||||
|
githubDarkDimmed,
|
||||||
|
// or a dynamic import if you want to do chunk splitting
|
||||||
|
// import('@shikijs/themes/material-theme-ocean')
|
||||||
|
],
|
||||||
|
langs: [
|
||||||
|
import('@shikijs/langs/log'),
|
||||||
|
import('@shikijs/langs/json'),
|
||||||
|
// shiki will try to interop the module with the default export
|
||||||
|
// () => import('@shikijs/langs/css'),
|
||||||
|
],
|
||||||
|
// `shiki/wasm` contains the wasm binary inlined as base64 string.
|
||||||
|
engine: createOnigurumaEngine(import('shiki/wasm'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// optionally, load themes and languages after creation
|
||||||
|
// await highlighter.loadTheme(import('@shikijs/themes/vitesse-light'))
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
$pausedSystems,
|
$pausedSystems,
|
||||||
$upSystems,
|
$upSystems,
|
||||||
} from "@/lib/stores"
|
} from "@/lib/stores"
|
||||||
import { FAVICON_DEFAULT, FAVICON_GREEN, FAVICON_RED, updateFavicon } from "@/lib/utils"
|
import { updateFavicon } from "@/lib/utils"
|
||||||
import type { SystemRecord } from "@/types"
|
import type { SystemRecord } from "@/types"
|
||||||
import { SystemStatus } from "./enums"
|
import { SystemStatus } from "./enums"
|
||||||
|
|
||||||
@@ -74,9 +74,7 @@ export function init() {
|
|||||||
|
|
||||||
/** Update the longest system name length and favicon based on system status */
|
/** Update the longest system name length and favicon based on system status */
|
||||||
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
|
function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: SystemRecord | undefined) {
|
||||||
const upSystemsStore = $upSystems.get()
|
|
||||||
const downSystemsStore = $downSystems.get()
|
const downSystemsStore = $downSystems.get()
|
||||||
const upSystems = Object.values(upSystemsStore)
|
|
||||||
const downSystems = Object.values(downSystemsStore)
|
const downSystems = Object.values(downSystemsStore)
|
||||||
|
|
||||||
// Update longest system name length
|
// Update longest system name length
|
||||||
@@ -86,14 +84,7 @@ function onSystemsChanged(_: Record<string, SystemRecord>, changedSystem: System
|
|||||||
$longestSystemNameLen.set(nameLen)
|
$longestSystemNameLen.set(nameLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update favicon based on system status
|
updateFavicon(downSystems.length)
|
||||||
if (downSystems.length > 0) {
|
|
||||||
updateFavicon(FAVICON_RED)
|
|
||||||
} else if (upSystems.length > 0) {
|
|
||||||
updateFavicon(FAVICON_GREEN)
|
|
||||||
} else {
|
|
||||||
updateFavicon(FAVICON_DEFAULT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch systems from collection */
|
/** Fetch systems from collection */
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { timeDay, timeHour } from "d3-time"
|
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
|
import { timeDay, timeHour, timeMinute } from "d3-time"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
import { prependBasePath } from "@/components/router"
|
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
|
||||||
import { HourFormat, MeterState, Unit } from "./enums"
|
import { HourFormat, MeterState, Unit } from "./enums"
|
||||||
import { $copyContent, $userSettings } from "./stores"
|
import { $copyContent, $userSettings } from "./stores"
|
||||||
|
|
||||||
export const FAVICON_DEFAULT = "favicon.svg"
|
|
||||||
export const FAVICON_GREEN = "favicon-green.svg"
|
|
||||||
export const FAVICON_RED = "favicon-red.svg"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
@@ -54,9 +49,18 @@ const createShortDateFormatter = (hour12?: boolean) =>
|
|||||||
hour12,
|
hour12,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createHourWithSecondsFormatter = (hour12?: boolean) =>
|
||||||
|
new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
hour12,
|
||||||
|
})
|
||||||
|
|
||||||
// Initialize formatters with default values
|
// Initialize formatters with default values
|
||||||
let hourWithMinutesFormatter = createHourWithMinutesFormatter()
|
let hourWithMinutesFormatter = createHourWithMinutesFormatter()
|
||||||
let shortDateFormatter = createShortDateFormatter()
|
let shortDateFormatter = createShortDateFormatter()
|
||||||
|
let hourWithSecondsFormatter = createHourWithSecondsFormatter()
|
||||||
|
|
||||||
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
|
export const currentHour12 = () => shortDateFormatter.resolvedOptions().hour12
|
||||||
|
|
||||||
@@ -68,6 +72,10 @@ export const formatShortDate = (timestamp: string) => {
|
|||||||
return shortDateFormatter.format(new Date(timestamp))
|
return shortDateFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const hourWithSeconds = (timestamp: string) => {
|
||||||
|
return hourWithSecondsFormatter.format(new Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
// Update the time formatters if user changes hourFormat
|
// Update the time formatters if user changes hourFormat
|
||||||
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
|
listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
|
||||||
if (!hourFormat) return
|
if (!hourFormat) return
|
||||||
@@ -75,6 +83,7 @@ listenKeys($userSettings, ["hourFormat"], ({ hourFormat }) => {
|
|||||||
if (currentHour12() !== newHour12) {
|
if (currentHour12() !== newHour12) {
|
||||||
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
|
hourWithMinutesFormatter = createHourWithMinutesFormatter(newHour12)
|
||||||
shortDateFormatter = createShortDateFormatter(newHour12)
|
shortDateFormatter = createShortDateFormatter(newHour12)
|
||||||
|
hourWithSecondsFormatter = createHourWithSecondsFormatter(newHour12)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -86,11 +95,47 @@ export const formatDay = (timestamp: string) => {
|
|||||||
return dayFormatter.format(new Date(timestamp))
|
return dayFormatter.format(new Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateFavicon = (newIcon: string) => {
|
export const updateFavicon = (() => {
|
||||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = prependBasePath(`/static/${newIcon}`)
|
let prevDownCount = 0
|
||||||
}
|
return (downCount = 0) => {
|
||||||
|
if (downCount === prevDownCount) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prevDownCount = downCount
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 70">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
|
<stop offset="0%" style="stop-color:#747bff"/>
|
||||||
|
<stop offset="100%" style="stop-color:#24eb5c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||||
|
${
|
||||||
|
downCount > 0 &&
|
||||||
|
`
|
||||||
|
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
||||||
|
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
export const chartTimeData: ChartTimeData = {
|
export const chartTimeData: ChartTimeData = {
|
||||||
|
"1m": {
|
||||||
|
type: "1m",
|
||||||
|
expectedInterval: 2000, // allow a bit of latency for one second updates (#1247)
|
||||||
|
label: () => t`1 minute`,
|
||||||
|
format: (timestamp: string) => hourWithSeconds(timestamp),
|
||||||
|
ticks: 3,
|
||||||
|
getOffset: (endTime: Date) => timeMinute.offset(endTime, -1),
|
||||||
|
minVersion: "0.13.0",
|
||||||
|
},
|
||||||
"1h": {
|
"1h": {
|
||||||
type: "1m",
|
type: "1m",
|
||||||
expectedInterval: 60_000,
|
expectedInterval: 60_000,
|
||||||
@@ -278,7 +323,7 @@ export const generateToken = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get the hub URL from the global BESZEL object */
|
/** Get the hub URL from the global BESZEL object */
|
||||||
export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin
|
export const getHubURL = () => globalThis.BESZEL?.HUB_URL || window.location.origin
|
||||||
|
|
||||||
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
/** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */
|
||||||
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
export const tokenMap = new Map<SystemRecord["id"], FingerprintRecord["token"]>()
|
||||||
@@ -322,6 +367,12 @@ export function formatDuration(
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse semver string into major, minor, and patch numbers
|
||||||
|
* @example
|
||||||
|
* const semVer = "1.2.3"
|
||||||
|
* const { major, minor, patch } = parseSemVer(semVer)
|
||||||
|
* console.log(major, minor, patch) // 1, 2, 3
|
||||||
|
*/
|
||||||
export const parseSemVer = (semVer = ""): SemVer => {
|
export const parseSemVer = (semVer = ""): SemVer => {
|
||||||
// if (semVer.startsWith("v")) {
|
// if (semVer.startsWith("v")) {
|
||||||
// semVer = semVer.slice(1)
|
// semVer = semVer.slice(1)
|
||||||
@@ -333,6 +384,17 @@ export const parseSemVer = (semVer = ""): SemVer => {
|
|||||||
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
|
return { major: parts?.[0] ?? 0, minor: parts?.[1] ?? 0, patch: parts?.[2] ?? 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compare two semver strings. Returns -1 if a is less than b, 0 if a is equal to b, and 1 if a is greater than b. */
|
||||||
|
export function compareSemVer(a: SemVer, b: SemVer) {
|
||||||
|
if (a.major !== b.major) {
|
||||||
|
return a.major - b.major
|
||||||
|
}
|
||||||
|
if (a.minor !== b.minor) {
|
||||||
|
return a.minor - b.minor
|
||||||
|
}
|
||||||
|
return a.patch - b.patch
|
||||||
|
}
|
||||||
|
|
||||||
/** Get meter state from 0-100 value. Used for color coding meters. */
|
/** Get meter state from 0-100 value. Used for color coding meters. */
|
||||||
export function getMeterState(value: number): MeterState {
|
export function getMeterState(value: number): MeterState {
|
||||||
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
|
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ msgstr ""
|
|||||||
"Language: ar\n"
|
"Language: ar\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-10-20 21:37\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Arabic\n"
|
"Language-Team: Arabic\n"
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: ar\n"
|
"X-Crowdin-Language: ar\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -48,6 +48,10 @@ msgstr "1 ساعة"
|
|||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr "دقيقة واحدة"
|
msgstr "دقيقة واحدة"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "1 minute"
|
||||||
|
msgstr "1 دقيقة"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 أسبوع"
|
msgstr "1 أسبوع"
|
||||||
@@ -85,7 +89,7 @@ msgstr "إجراءات"
|
|||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "نشط"
|
msgstr "نشط"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "التنبيهات النشطة"
|
msgstr "التنبيهات النشطة"
|
||||||
|
|
||||||
@@ -129,7 +133,15 @@ msgstr "سجل التنبيهات"
|
|||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
msgstr "التنبيهات"
|
msgstr "التنبيهات"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/containers.tsx
|
||||||
|
msgid "All Containers"
|
||||||
|
msgstr "جميع الحاويات"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/home.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -222,6 +234,10 @@ msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "إلغاء"
|
msgstr "إلغاء"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "السعة"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "تحذير - فقدان محتمل للبيانات"
|
msgstr "تحذير - فقدان محتمل للبيانات"
|
||||||
@@ -263,6 +279,14 @@ msgstr "تحقق من السجلات لمزيد من التفاصيل."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
|
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Click on a container to view more information."
|
||||||
|
msgstr "انقر على حاوية لعرض مزيد من المعلومات."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "انقر على جهاز لعرض مزيد من المعلومات."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "انقر على نظام لعرض مزيد من المعلومات."
|
msgstr "انقر على نظام لعرض مزيد من المعلومات."
|
||||||
@@ -285,7 +309,7 @@ msgstr "هيئ التنبيهات الواردة"
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "تأكيد كلمة المرور"
|
msgstr "تأكيد كلمة المرور"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "الاتصال مقطوع"
|
msgstr "الاتصال مقطوع"
|
||||||
|
|
||||||
@@ -344,6 +368,7 @@ msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه،
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "نسخ YAML"
|
msgstr "نسخ YAML"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-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 "المعالج"
|
||||||
@@ -380,8 +405,12 @@ msgstr "الرفع التراكمي"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "الحالة الحالية"
|
msgstr "الحالة الحالية"
|
||||||
|
|
||||||
|
#. Power Cycles
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Cycles"
|
||||||
|
msgstr "الدورات"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "لوحة التحكم"
|
msgstr "لوحة التحكم"
|
||||||
|
|
||||||
@@ -398,6 +427,14 @@ msgstr "حذف"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "حذف البصمة"
|
msgstr "حذف البصمة"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Detail"
|
||||||
|
msgstr "التفاصيل"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "الجهاز"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -504,7 +541,7 @@ msgstr "خطأ"
|
|||||||
#. placeholder {0}: alert.value
|
#. placeholder {0}: alert.value
|
||||||
#. placeholder {1}: info.unit
|
#. placeholder {1}: info.unit
|
||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
|
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
|
||||||
|
|
||||||
@@ -528,6 +565,10 @@ msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "فهرنهايت (°ف)"
|
msgstr "فهرنهايت (°ف)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "السمات الفاشلة:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "فشل في المصادقة"
|
msgstr "فشل في المصادقة"
|
||||||
@@ -545,8 +586,10 @@ msgstr "فشل في إرسال إشعار الاختبار"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "فشل في تحديث التنبيه"
|
msgstr "فشل في تحديث التنبيه"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "تصفية..."
|
msgstr "تصفية..."
|
||||||
@@ -555,6 +598,10 @@ msgstr "تصفية..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "البصمة"
|
msgstr "البصمة"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "البرمجيات الثابتة"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
|
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
|
||||||
@@ -592,6 +639,10 @@ msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
|
|||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "شبكة"
|
msgstr "شبكة"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Health"
|
||||||
|
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
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
@@ -611,6 +662,11 @@ msgstr "خاملة"
|
|||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
||||||
msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي."
|
msgstr "إذا فقدت كلمة المرور لحساب المسؤول الخاص بك، يمكنك إعادة تعيينها باستخدام الأمر التالي."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgctxt "Docker image"
|
||||||
|
msgid "Image"
|
||||||
|
msgstr "صورة"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "عنوان البريد الإشباكي غير صالح."
|
msgstr "عنوان البريد الإشباكي غير صالح."
|
||||||
@@ -663,6 +719,7 @@ msgid "Login attempt failed"
|
|||||||
msgstr "فشل محاولة تسجيل الدخول"
|
msgstr "فشل محاولة تسجيل الدخول"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "السجلات"
|
msgstr "السجلات"
|
||||||
@@ -685,6 +742,7 @@ msgstr "تعليمات الإعداد اليدوي"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "الحد الأقصى دقيقة"
|
msgstr "الحد الأقصى دقيقة"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "الذاكرة"
|
msgstr "الذاكرة"
|
||||||
@@ -698,11 +756,17 @@ msgstr "استخدام الذاكرة"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "استخدام الذاكرة لحاويات دوكر"
|
msgstr "استخدام الذاكرة لحاويات دوكر"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "الموديل"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "الاسم"
|
msgstr "الاسم"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "الشبكة"
|
msgstr "الشبكة"
|
||||||
@@ -727,10 +791,18 @@ msgstr "وحدة الشبكة"
|
|||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "لم يتم العثور على نتائج."
|
msgstr "لم يتم العثور على نتائج."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "لا توجد نتائج."
|
msgstr "لا توجد نتائج."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "لا توجد سمات S.M.A.R.T. متاحة لهذا الجهاز."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -768,6 +840,7 @@ msgstr "أو المتابعة باستخدام"
|
|||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "الكتابة فوق التنبيهات الحالية"
|
msgstr "الكتابة فوق التنبيهات الحالية"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "صفحة"
|
msgstr "صفحة"
|
||||||
@@ -848,6 +921,11 @@ msgstr "يرجى تسجيل الدخول إلى حسابك"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "المنفذ"
|
msgstr "المنفذ"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "تشغيل الطاقة"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -872,6 +950,11 @@ msgstr "قراءة"
|
|||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "تم الاستلام"
|
msgstr "تم الاستلام"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Refresh"
|
||||||
|
msgstr "تحديث"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "طلب كلمة مرور لمرة واحدة"
|
msgstr "طلب كلمة مرور لمرة واحدة"
|
||||||
@@ -902,6 +985,14 @@ msgstr "تدوير الرمز المميز"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "صفوف لكل صفحة"
|
msgstr "صفوف لكل صفحة"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "تفاصيل S.M.A.R.T."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "اختبار S.M.A.R.T. الذاتي"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
||||||
@@ -931,6 +1022,10 @@ msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفي
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "تم الإرسال"
|
msgstr "تم الإرسال"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "الرقم التسلسلي"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
|
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
|
||||||
@@ -963,6 +1058,8 @@ msgstr "الترتيب حسب"
|
|||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "الحالة"
|
msgstr "الحالة"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
@@ -977,6 +1074,7 @@ msgid "Swap Usage"
|
|||||||
msgstr "استخدام التبديل"
|
msgstr "استخدام التبديل"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -1000,6 +1098,7 @@ msgid "Table"
|
|||||||
msgstr "جدول"
|
msgstr "جدول"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "درجة الحرارة"
|
msgstr "درجة الحرارة"
|
||||||
@@ -1125,6 +1224,10 @@ msgstr "يتم التفعيل عندما يتغير الحالة بين التش
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "النوع"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1150,6 +1253,10 @@ msgstr "قيد التشغيل"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "قيد التشغيل ({upSystemsLength})"
|
msgstr "قيد التشغيل ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "تم التحديث"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "رفع"
|
msgstr "رفع"
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ 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: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-10-20 21:37\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"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: bg\n"
|
"X-Crowdin-Language: bg\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -48,6 +48,10 @@ msgstr "1 час"
|
|||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr "1 минута"
|
msgstr "1 минута"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "1 minute"
|
||||||
|
msgstr "1 минута"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 седмица"
|
msgstr "1 седмица"
|
||||||
@@ -85,7 +89,7 @@ msgstr "Действия"
|
|||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Активен"
|
msgstr "Активен"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Активни тревоги"
|
msgstr "Активни тревоги"
|
||||||
|
|
||||||
@@ -129,7 +133,15 @@ msgstr "История на нотификациите"
|
|||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
msgstr "Тревоги"
|
msgstr "Тревоги"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/containers.tsx
|
||||||
|
msgid "All Containers"
|
||||||
|
msgstr "Всички контейнери"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/home.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -222,6 +234,10 @@ msgstr "Кеш / Буфери"
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Откажи"
|
msgstr "Откажи"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Капацитет"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Внимание - възможност за загуба на данни"
|
msgstr "Внимание - възможност за загуба на данни"
|
||||||
@@ -263,6 +279,14 @@ msgstr "Провери log-овете за повече информация."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Провери услугата си за удостоверяване"
|
msgstr "Провери услугата си за удостоверяване"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Click on a container to view more information."
|
||||||
|
msgstr "Кликнете върху контейнер, за да видите повече информация."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Кликнете върху устройство, за да видите повече информация."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "Кликнете върху система, за да видите повече информация."
|
msgstr "Кликнете върху система, за да видите повече информация."
|
||||||
@@ -285,7 +309,7 @@ msgstr "Настрой как получаваш нотификации за т
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Потвърди парола"
|
msgstr "Потвърди парола"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "Връзката е прекъсната"
|
msgstr "Връзката е прекъсната"
|
||||||
|
|
||||||
@@ -344,6 +368,7 @@ msgstr "Копирайте съдържанието на<0>docker-compose.yml</0
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "Копирай YAML"
|
msgstr "Копирай YAML"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-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 "Процесор"
|
||||||
@@ -380,8 +405,12 @@ msgstr "Кумулативно качване"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Текущо състояние"
|
msgstr "Текущо състояние"
|
||||||
|
|
||||||
|
#. Power Cycles
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Cycles"
|
||||||
|
msgstr "Цикли"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "Табло"
|
msgstr "Табло"
|
||||||
|
|
||||||
@@ -398,6 +427,14 @@ msgstr "Изтрий"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Изтрий пръстов отпечатък"
|
msgstr "Изтрий пръстов отпечатък"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Detail"
|
||||||
|
msgstr "Подробности"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Устройство"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -504,7 +541,7 @@ msgstr "Грешка"
|
|||||||
#. placeholder {0}: alert.value
|
#. placeholder {0}: alert.value
|
||||||
#. placeholder {1}: info.unit
|
#. placeholder {1}: info.unit
|
||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}"
|
msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}"
|
||||||
|
|
||||||
@@ -528,6 +565,10 @@ msgstr "Експортирай конфигурацията на системи
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "Фаренхайт (°F)"
|
msgstr "Фаренхайт (°F)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Неуспешни атрибути:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Неуспешно удостоверяване"
|
msgstr "Неуспешно удостоверяване"
|
||||||
@@ -545,8 +586,10 @@ msgstr "Неуспешно изпрати тестова нотификация"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Неуспешно обнови тревога"
|
msgstr "Неуспешно обнови тревога"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Филтрирай..."
|
msgstr "Филтрирай..."
|
||||||
@@ -555,6 +598,10 @@ msgstr "Филтрирай..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "Пръстов отпечатък"
|
msgstr "Пръстов отпечатък"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "Фърмуер"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "За <0>{min}</0> {min, plural, one {минута} other {минути}}"
|
msgstr "За <0>{min}</0> {min, plural, one {минута} other {минути}}"
|
||||||
@@ -592,6 +639,10 @@ msgstr "Консумация на ток от графична карта"
|
|||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Мрежово"
|
msgstr "Мрежово"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Health"
|
||||||
|
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
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
@@ -611,6 +662,11 @@ msgstr "Неактивна"
|
|||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
||||||
msgstr "Ако си загубил паролата до администраторския акаунт, можеш да я нулираш със следващата команда."
|
msgstr "Ако си загубил паролата до администраторския акаунт, можеш да я нулираш със следващата команда."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgctxt "Docker image"
|
||||||
|
msgid "Image"
|
||||||
|
msgstr "Образ"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Невалиден имейл адрес."
|
msgstr "Невалиден имейл адрес."
|
||||||
@@ -663,6 +719,7 @@ msgid "Login attempt failed"
|
|||||||
msgstr "Неуспешен опит за вход"
|
msgstr "Неуспешен опит за вход"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "Логове"
|
msgstr "Логове"
|
||||||
@@ -685,6 +742,7 @@ msgstr "Инструкции за ръчна настройка"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Максимум 1 минута"
|
msgstr "Максимум 1 минута"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Памет"
|
msgstr "Памет"
|
||||||
@@ -698,11 +756,17 @@ msgstr "Употреба на паметта"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Използването на памет от docker контейнерите"
|
msgstr "Използването на памет от docker контейнерите"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Модел"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Име"
|
msgstr "Име"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Мрежа"
|
msgstr "Мрежа"
|
||||||
@@ -727,10 +791,18 @@ msgstr "Единица за измерване на скорост"
|
|||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Няма намерени резултати."
|
msgstr "Няма намерени резултати."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "Няма резултати."
|
msgstr "Няма резултати."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Няма налични S.M.A.R.T. атрибути за това устройство."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -768,6 +840,7 @@ msgstr "Или продължи с"
|
|||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Презапиши съществуващи тревоги"
|
msgstr "Презапиши съществуващи тревоги"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Страница"
|
msgstr "Страница"
|
||||||
@@ -848,6 +921,11 @@ msgstr "Моля влез в акаунта ти"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Порт"
|
msgstr "Порт"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Включване"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -872,6 +950,11 @@ msgstr "Прочети"
|
|||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Получени"
|
msgstr "Получени"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Refresh"
|
||||||
|
msgstr "Опресни"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Заявка за еднократна парола"
|
msgstr "Заявка за еднократна парола"
|
||||||
@@ -902,6 +985,14 @@ msgstr "Пресъздаване на идентификатора"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "Редове на страница"
|
msgstr "Редове на страница"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T. Детайли"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T. Самотест"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
||||||
@@ -931,6 +1022,10 @@ msgstr "Виж <0>настройките за нотификациите</0> з
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Изпратени"
|
msgstr "Изпратени"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Сериен номер"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
|
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
|
||||||
@@ -963,6 +1058,8 @@ msgstr "Сортиране по"
|
|||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "Състояние"
|
msgstr "Състояние"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
@@ -977,6 +1074,7 @@ msgid "Swap Usage"
|
|||||||
msgstr "Използване на swap"
|
msgstr "Използване на swap"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -1000,6 +1098,7 @@ msgid "Table"
|
|||||||
msgstr "Таблица"
|
msgstr "Таблица"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Температура"
|
msgstr "Температура"
|
||||||
@@ -1125,6 +1224,10 @@ msgstr "Задейства се, когато статуса превключв
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Тип"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1150,6 +1253,10 @@ msgstr "Нагоре"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "Нагоре ({upSystemsLength})"
|
msgstr "Нагоре ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Актуализирано"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Качване"
|
msgstr "Качване"
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ msgstr ""
|
|||||||
"Language: cs\n"
|
"Language: cs\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-10-20 21:37\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Czech\n"
|
"Language-Team: Czech\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: cs\n"
|
"X-Crowdin-Language: cs\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -48,6 +48,10 @@ msgstr "1 hodina"
|
|||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr "1 min"
|
msgstr "1 min"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "1 minute"
|
||||||
|
msgstr "1 minuta"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 týden"
|
msgstr "1 týden"
|
||||||
@@ -85,7 +89,7 @@ msgstr "Akce"
|
|||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktivní"
|
msgstr "Aktivní"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktivní výstrahy"
|
msgstr "Aktivní výstrahy"
|
||||||
|
|
||||||
@@ -112,7 +116,7 @@ msgstr "Upravit možnosti zobrazení pro grafy."
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Administrátor"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
@@ -129,7 +133,15 @@ msgstr "Historie upozornění"
|
|||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
msgstr "Výstrahy"
|
msgstr "Výstrahy"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/containers.tsx
|
||||||
|
msgid "All Containers"
|
||||||
|
msgstr "Všechny kontejnery"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/home.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -201,17 +213,17 @@ msgstr "Beszel používá <0>Shoutrrr</0> k integraci s populárními notifikač
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binary"
|
msgstr "Binární"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr "Bits (Kbps, Mbps, Gbps)"
|
msgstr "Bity (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
msgstr "Byty (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
@@ -222,6 +234,10 @@ msgstr "Cache / vyrovnávací paměť"
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Zrušit"
|
msgstr "Zrušit"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Kapacita"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Upozornění - možná ztráta dat"
|
msgstr "Upozornění - možná ztráta dat"
|
||||||
@@ -263,6 +279,14 @@ msgstr "Pro více informací zkontrolujte logy."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Zkontrolujte službu upozornění"
|
msgstr "Zkontrolujte službu upozornění"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Click on a container to view more information."
|
||||||
|
msgstr "Klikněte na kontejner pro zobrazení dalších informací."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Klikněte na zařízení pro zobrazení dalších informací."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "Klikněte na systém pro zobrazení více informací."
|
msgstr "Klikněte na systém pro zobrazení více informací."
|
||||||
@@ -285,7 +309,7 @@ msgstr "Konfigurace způsobu přijímání upozornění."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Potvrdit heslo"
|
msgstr "Potvrdit heslo"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "Připojení je nedostupné"
|
msgstr "Připojení je nedostupné"
|
||||||
|
|
||||||
@@ -344,6 +368,7 @@ msgstr "Zkopírujte obsah <0>docker-compose.yml</0> pro agenta níže nebo autom
|
|||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "Kopírovat YAML"
|
msgstr "Kopírovat YAML"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "Procesor"
|
msgstr "Procesor"
|
||||||
@@ -380,8 +405,12 @@ msgstr "Kumulativní odeslání"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Aktuální stav"
|
msgstr "Aktuální stav"
|
||||||
|
|
||||||
|
#. Power Cycles
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Cycles"
|
||||||
|
msgstr "Cykly"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "Přehled"
|
msgstr "Přehled"
|
||||||
|
|
||||||
@@ -398,6 +427,14 @@ msgstr "Odstranit"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Smazat identifikátor"
|
msgstr "Smazat identifikátor"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Detail"
|
||||||
|
msgstr "Detail"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Zařízení"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -504,7 +541,7 @@ msgstr "Chyba"
|
|||||||
#. placeholder {0}: alert.value
|
#. placeholder {0}: alert.value
|
||||||
#. placeholder {1}: info.unit
|
#. placeholder {1}: info.unit
|
||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
|
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
|
||||||
|
|
||||||
@@ -514,7 +551,7 @@ msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, bu
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
msgstr "Export"
|
msgstr "Exportovat"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
@@ -528,6 +565,10 @@ msgstr "Exportovat aktuální konfiguraci systémů."
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "Fahrenheita (°F)"
|
msgstr "Fahrenheita (°F)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Neúspěšné atributy:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Ověření se nezdařilo"
|
msgstr "Ověření se nezdařilo"
|
||||||
@@ -545,8 +586,10 @@ msgstr "Nepodařilo se odeslat testovací oznámení"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Nepodařilo se aktualizovat upozornění"
|
msgstr "Nepodařilo se aktualizovat upozornění"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filtr..."
|
msgstr "Filtr..."
|
||||||
@@ -555,6 +598,10 @@ msgstr "Filtr..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "Otisk"
|
msgstr "Otisk"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "Firmware"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
|
msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
|
||||||
@@ -592,6 +639,10 @@ msgstr "Spotřeba energie GPU"
|
|||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Mřížka"
|
msgstr "Mřížka"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Health"
|
||||||
|
msgstr "Zdraví"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
@@ -611,6 +662,11 @@ msgstr "Neaktivní"
|
|||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
||||||
msgstr "Pokud jste ztratili heslo k vašemu účtu správce, můžete jej obnovit pomocí následujícího příkazu."
|
msgstr "Pokud jste ztratili heslo k vašemu účtu správce, můžete jej obnovit pomocí následujícího příkazu."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgctxt "Docker image"
|
||||||
|
msgid "Image"
|
||||||
|
msgstr "Obraz"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Neplatná e-mailová adresa."
|
msgstr "Neplatná e-mailová adresa."
|
||||||
@@ -618,7 +674,7 @@ msgstr "Neplatná e-mailová adresa."
|
|||||||
#. Linux kernel
|
#. Linux kernel
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Jádro"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
@@ -663,6 +719,7 @@ msgid "Login attempt failed"
|
|||||||
msgstr "Pokus o přihlášení selhal"
|
msgstr "Pokus o přihlášení selhal"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "Logy"
|
msgstr "Logy"
|
||||||
@@ -685,6 +742,7 @@ msgstr "Pokyny k manuálnímu nastavení"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max. 1 min"
|
msgstr "Max. 1 min"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Paměť"
|
msgstr "Paměť"
|
||||||
@@ -698,11 +756,17 @@ msgstr "Využití paměti"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Využití paměti docker kontejnerů"
|
msgstr "Využití paměti docker kontejnerů"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Model"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Název"
|
msgstr "Název"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Síť"
|
msgstr "Síť"
|
||||||
@@ -727,10 +791,18 @@ msgstr "Síťová jednotka"
|
|||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Nenalezeny žádné výskyty."
|
msgstr "Nenalezeny žádné výskyty."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "Žádné výsledky."
|
msgstr "Žádné výsledky."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Pro toto zařízení nejsou k dispozici žádné atributy S.M.A.R.T."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -768,6 +840,7 @@ msgstr "Nebo pokračujte s"
|
|||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Přepsat existující upozornění"
|
msgstr "Přepsat existující upozornění"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Stránka"
|
msgstr "Stránka"
|
||||||
@@ -848,6 +921,11 @@ msgstr "Přihlaste se prosím k vašemu účtu"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Port"
|
msgstr "Port"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Zapnutí"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -872,6 +950,11 @@ msgstr "Číst"
|
|||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Přijato"
|
msgstr "Přijato"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Refresh"
|
||||||
|
msgstr "Aktualizovat"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Požádat o jednorázové heslo"
|
msgstr "Požádat o jednorázové heslo"
|
||||||
@@ -902,6 +985,14 @@ msgstr "Změnit token"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "Řádků na stránku"
|
msgstr "Řádků na stránku"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T. Detaily"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T. Vlastní test"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
|
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
|
||||||
@@ -931,6 +1022,10 @@ msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Odeslat"
|
msgstr "Odeslat"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Sériové číslo"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
|
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
|
||||||
@@ -963,6 +1058,8 @@ msgstr "Seřadit podle"
|
|||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "Stav"
|
msgstr "Stav"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
@@ -977,6 +1074,7 @@ msgid "Swap Usage"
|
|||||||
msgstr "Swap využití"
|
msgstr "Swap využití"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -1000,6 +1098,7 @@ msgid "Table"
|
|||||||
msgstr "Tabulka"
|
msgstr "Tabulka"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Teplota"
|
msgstr "Teplota"
|
||||||
@@ -1019,7 +1118,7 @@ msgstr "Teploty systémových senzorů"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test <0>URL</0>"
|
msgid "Test <0>URL</0>"
|
||||||
msgstr "Test <0>URL</0>"
|
msgstr "Testovat <0>URL</0>"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test notification sent"
|
msgid "Test notification sent"
|
||||||
@@ -1125,6 +1224,10 @@ msgstr "Spouští se, když se změní dostupnost"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Typ"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1150,6 +1253,10 @@ msgstr "Funkční"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "Funkční ({upSystemsLength})"
|
msgstr "Funkční ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Aktualizováno"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Odeslání"
|
msgstr "Odeslání"
|
||||||
|
|||||||
@@ -8,36 +8,36 @@ msgstr ""
|
|||||||
"Language: da\n"
|
"Language: da\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-10-25 10:58\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Danish\n"
|
"Language-Team: Danish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: da\n"
|
"X-Crowdin-Language: da\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
msgid "{0, plural, one {# day} other {# days}}"
|
||||||
msgstr "{0, plural, one {# day} other {# days}}"
|
msgstr "{0, plural, one {# dag} other {# dage}}"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 3600)
|
#. placeholder {0}: Math.trunc(system.info.u / 3600)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{0, plural, one {# hour} other {# hours}}"
|
msgid "{0, plural, one {# hour} other {# hours}}"
|
||||||
msgstr "{0, plural, one {# hour} other {# hours}}"
|
msgstr "{0, plural, one {# time} other {# timer}}"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 60)
|
#. placeholder {0}: Math.trunc(system.info.u / 60)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
|
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
|
||||||
msgstr ""
|
msgstr "{0, plural, one {# minut} other {# minutter}}"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "{0} of {1} row(s) selected."
|
msgid "{0} of {1} row(s) selected."
|
||||||
msgstr ""
|
msgstr "{0} af {1} række(r) valgt."
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
@@ -46,7 +46,11 @@ msgstr "1 time"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 minut"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "1 minute"
|
||||||
|
msgstr "1 minut"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
@@ -59,7 +63,7 @@ msgstr "12 timer"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 minutter"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -72,7 +76,7 @@ msgstr "30 dage"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/charts/load-average-chart.tsx
|
#: src/components/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 minutter"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -83,9 +87,9 @@ msgstr "Handlinger"
|
|||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr ""
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktive Alarmer"
|
msgstr "Aktive Alarmer"
|
||||||
|
|
||||||
@@ -112,7 +116,7 @@ msgstr "Juster visningsindstillinger for diagrammer."
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Administrator"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
@@ -122,14 +126,22 @@ msgstr "Agent"
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Alert History"
|
msgid "Alert History"
|
||||||
msgstr ""
|
msgstr "Advarselshistorik"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
msgstr "Alarmer"
|
msgstr "Alarmer"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/containers.tsx
|
||||||
|
msgid "All Containers"
|
||||||
|
msgstr "Alle containere"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/home.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -141,7 +153,7 @@ msgstr "Er du sikker på, at du vil slette {name}?"
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Are you sure?"
|
msgid "Are you sure?"
|
||||||
msgstr ""
|
msgstr "Er du sikker?"
|
||||||
|
|
||||||
#: src/components/copy-to-clipboard.tsx
|
#: src/components/copy-to-clipboard.tsx
|
||||||
msgid "Automatic copy requires a secure context."
|
msgid "Automatic copy requires a secure context."
|
||||||
@@ -175,7 +187,7 @@ msgstr "Gennemsnitlig udnyttelse af {0}"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Average utilization of GPU engines"
|
msgid "Average utilization of GPU engines"
|
||||||
msgstr "Gennemsnitlig udnyttelse af GPU-motorer"
|
msgstr "Gennemsnitlig udnyttelse af GPU-enheder"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
@@ -206,12 +218,12 @@ msgstr "Binær"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr ""
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr ""
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
@@ -222,17 +234,21 @@ msgstr "Cache / Buffere"
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Fortryd"
|
msgstr "Fortryd"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Kapacitet"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Forsigtig - muligt tab af data"
|
msgstr "Forsigtig - muligt tab af data"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
msgstr ""
|
msgstr "Ændre viste enheder for målinger."
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change general application options."
|
msgid "Change general application options."
|
||||||
@@ -263,9 +279,17 @@ msgstr "Tjek logfiler for flere detaljer."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Tjek din notifikationstjeneste"
|
msgstr "Tjek din notifikationstjeneste"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Click on a container to view more information."
|
||||||
|
msgstr "Klik på en container for at se mere information."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Klik på en enhed for at se flere oplysninger."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr ""
|
msgstr "Klik på et system for at se mere information."
|
||||||
|
|
||||||
#: src/components/ui/input-copy.tsx
|
#: src/components/ui/input-copy.tsx
|
||||||
msgid "Click to copy"
|
msgid "Click to copy"
|
||||||
@@ -285,9 +309,9 @@ msgstr "Konfigurer hvordan du modtager advarselsmeddelelser."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Bekræft adgangskode"
|
msgstr "Bekræft adgangskode"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr ""
|
msgstr "Forbindelsen er nede"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -313,7 +337,7 @@ msgstr "Kopiér docker run"
|
|||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Environment variables"
|
msgctxt "Environment variables"
|
||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr ""
|
msgstr "Kopier miljø"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
@@ -334,16 +358,17 @@ msgstr "Kopier tekst"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
||||||
msgstr ""
|
msgstr "Kopier installationskommandoen for agenten nedenfor, eller registrer agenter automatisk med en <0>universalnøgle</0>."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
||||||
msgstr ""
|
msgstr "Kopier <0>docker-compose.yml</0> indholdet for agenten nedenfor, eller registrer agenter automatisk med en <1>universalnøgle</1>."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr ""
|
msgstr "Kopier YAML"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
@@ -361,7 +386,7 @@ msgstr "Opret konto"
|
|||||||
#. Context: date created
|
#. Context: date created
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Created"
|
msgid "Created"
|
||||||
msgstr ""
|
msgstr "Oprettet"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Critical (%)"
|
msgid "Critical (%)"
|
||||||
@@ -380,8 +405,12 @@ msgstr "Kumulativ upload"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Nuværende tilstand"
|
msgstr "Nuværende tilstand"
|
||||||
|
|
||||||
|
#. Power Cycles
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Cycles"
|
||||||
|
msgstr "Cykler"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "Oversigtspanel"
|
msgstr "Oversigtspanel"
|
||||||
|
|
||||||
@@ -396,7 +425,15 @@ msgstr "Slet"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr ""
|
msgstr "Slet fingeraftryk"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Detail"
|
||||||
|
msgstr "Detalje"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Enhed"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
@@ -413,7 +450,7 @@ msgstr "Disk I/O"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Disk unit"
|
msgid "Disk unit"
|
||||||
msgstr ""
|
msgstr "Diskenhed"
|
||||||
|
|
||||||
#: src/components/charts/disk-chart.tsx
|
#: src/components/charts/disk-chart.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -451,15 +488,15 @@ msgstr "Nede"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Down ({downSystemsLength})"
|
msgid "Down ({downSystemsLength})"
|
||||||
msgstr ""
|
msgstr "Nede ({downSystemsLength})"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Download"
|
msgid "Download"
|
||||||
msgstr "Download"
|
msgstr "Hent ned"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Duration"
|
msgid "Duration"
|
||||||
msgstr ""
|
msgstr "Varighed"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -504,7 +541,7 @@ msgstr "Fejl"
|
|||||||
#. placeholder {0}: alert.value
|
#. placeholder {0}: alert.value
|
||||||
#. placeholder {1}: info.unit
|
#. placeholder {1}: info.unit
|
||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Overskrider {0}{1} i sidste {2, plural, one {# minut} other {# minutter}}"
|
msgstr "Overskrider {0}{1} i sidste {2, plural, one {# minut} other {# minutter}}"
|
||||||
|
|
||||||
@@ -514,7 +551,7 @@ msgstr "Eksisterende systemer ikke defineret i <0>config.yml</0> vil blive slett
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
msgstr ""
|
msgstr "Eksporter"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
@@ -526,7 +563,11 @@ msgstr "Eksporter din nuværende systemkonfiguration."
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr ""
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Mislykkede attributter:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
@@ -545,15 +586,21 @@ msgstr "Afsendelse af testnotifikation mislykkedes"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Kunne ikke opdatere alarm"
|
msgstr "Kunne ikke opdatere alarm"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filter..."
|
msgstr "Filter..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr ""
|
msgstr "Fingeraftryk"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "Firmware"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -582,7 +629,7 @@ msgstr "Generelt"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
msgstr "GPU-motorer"
|
msgstr "GPU-enheder"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Power Draw"
|
msgid "GPU Power Draw"
|
||||||
@@ -592,6 +639,10 @@ msgstr "Gpu Strøm Træk"
|
|||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Gitter"
|
msgstr "Gitter"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Health"
|
||||||
|
msgstr "Sundhed"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
@@ -611,6 +662,11 @@ msgstr "Inaktiv"
|
|||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
||||||
msgstr "Hvis du har mistet adgangskoden til din administratorkonto, kan du nulstille den ved hjælp af følgende kommando."
|
msgstr "Hvis du har mistet adgangskoden til din administratorkonto, kan du nulstille den ved hjælp af følgende kommando."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgctxt "Docker image"
|
||||||
|
msgid "Image"
|
||||||
|
msgstr "Billede"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Ugyldig email adresse."
|
msgstr "Ugyldig email adresse."
|
||||||
@@ -618,7 +674,7 @@ msgstr "Ugyldig email adresse."
|
|||||||
#. Linux kernel
|
#. Linux kernel
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Kerne"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
@@ -626,28 +682,28 @@ msgstr "Sprog"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Layout"
|
msgid "Layout"
|
||||||
msgstr "Layout"
|
msgstr "Opstilling"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Load Average"
|
msgid "Load Average"
|
||||||
msgstr ""
|
msgstr "Belastning Gennemsnitlig"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Load Average 15m"
|
msgid "Load Average 15m"
|
||||||
msgstr ""
|
msgstr "Belastning Gennemsnitlig 15m"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Load Average 1m"
|
msgid "Load Average 1m"
|
||||||
msgstr ""
|
msgstr "Belastning Gennemsnitlig 1m"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Load Average 5m"
|
msgid "Load Average 5m"
|
||||||
msgstr ""
|
msgstr "Belastning Gennemsnitlig 5m"
|
||||||
|
|
||||||
#. Short label for load average
|
#. Short label for load average
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Load Avg"
|
msgid "Load Avg"
|
||||||
msgstr ""
|
msgstr "Belastning gns."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
@@ -663,6 +719,7 @@ msgid "Login attempt failed"
|
|||||||
msgstr "Loginforsøg mislykkedes"
|
msgstr "Loginforsøg mislykkedes"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "Logs"
|
msgstr "Logs"
|
||||||
@@ -685,6 +742,7 @@ msgstr "Manuel opsætningsvejledning"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Maks. 1 min"
|
msgstr "Maks. 1 min"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Hukommelse"
|
msgstr "Hukommelse"
|
||||||
@@ -698,11 +756,17 @@ msgstr "Hukommelsesforbrug"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Hukommelsesforbrug af dockercontainere"
|
msgstr "Hukommelsesforbrug af dockercontainere"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Model"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Navn"
|
msgstr "Navn"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Net"
|
msgstr "Net"
|
||||||
@@ -721,15 +785,23 @@ msgstr "Netværkstrafik af offentlige grænseflader"
|
|||||||
#. Context: Bytes or bits
|
#. Context: Bytes or bits
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Network unit"
|
msgid "Network unit"
|
||||||
msgstr ""
|
msgstr "Netværksenhed"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Ingen resultater fundet."
|
msgstr "Ingen resultater fundet."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr ""
|
msgstr "Ingen resultater."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Ingen S.M.A.R.T.-attributter tilgængelige for denne enhed."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
@@ -768,6 +840,7 @@ msgstr "Eller fortsæt med"
|
|||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Overskriv eksisterende alarmer"
|
msgstr "Overskriv eksisterende alarmer"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Side"
|
msgstr "Side"
|
||||||
@@ -776,7 +849,7 @@ msgstr "Side"
|
|||||||
#. placeholder {1}: table.getPageCount()
|
#. placeholder {1}: table.getPageCount()
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Page {0} of {1}"
|
msgid "Page {0} of {1}"
|
||||||
msgstr ""
|
msgstr "Side {0} af {1}"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Pages / Settings"
|
msgid "Pages / Settings"
|
||||||
@@ -809,7 +882,7 @@ msgstr "Sat på pause"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Paused ({pausedSystemsLength})"
|
msgid "Paused ({pausedSystemsLength})"
|
||||||
msgstr ""
|
msgstr "Sat på pause ({pausedSystemsLength})"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
@@ -848,6 +921,11 @@ msgstr "Log venligst ind på din konto"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Port"
|
msgstr "Port"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Tænd"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -872,6 +950,11 @@ msgstr "Læs"
|
|||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Modtaget"
|
msgstr "Modtaget"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Refresh"
|
||||||
|
msgstr "Opdater"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Anmod om engangsadgangskode"
|
msgstr "Anmod om engangsadgangskode"
|
||||||
@@ -888,7 +971,7 @@ msgstr "Nulstil adgangskode"
|
|||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Resolved"
|
msgid "Resolved"
|
||||||
msgstr ""
|
msgstr "Løst"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
@@ -896,11 +979,19 @@ msgstr "Genoptag"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr ""
|
msgstr "Roter nøgle"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr ""
|
msgstr "Rækker per side"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T.-detaljer"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T. selvtest"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
@@ -931,6 +1022,10 @@ msgstr "Se <0>meddelelsesindstillinger</0> for at konfigurere, hvordan du modtag
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Sendt"
|
msgstr "Sendt"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Serienummer"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Indstil procentvise tærskler for målerfarver."
|
msgstr "Indstil procentvise tærskler for målerfarver."
|
||||||
@@ -961,8 +1056,10 @@ msgstr "Sorter efter"
|
|||||||
#. Context: alert state (active or resolved)
|
#. Context: alert state (active or resolved)
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr ""
|
msgstr "Tilstand"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
@@ -977,6 +1074,7 @@ msgid "Swap Usage"
|
|||||||
msgstr "Swap forbrug"
|
msgstr "Swap forbrug"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -985,7 +1083,7 @@ msgstr "System"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "System load averages over time"
|
msgid "System load averages over time"
|
||||||
msgstr ""
|
msgstr "Gennemsnitlig system belastning over tid"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
@@ -1000,6 +1098,7 @@ msgid "Table"
|
|||||||
msgstr "Tabel"
|
msgstr "Tabel"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Temperatur"
|
msgstr "Temperatur"
|
||||||
@@ -1011,7 +1110,7 @@ msgstr "Temperatur"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Temperature unit"
|
msgid "Temperature unit"
|
||||||
msgstr ""
|
msgstr "Temperaturenhed"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Temperatures of system sensors"
|
msgid "Temperatures of system sensors"
|
||||||
@@ -1035,7 +1134,7 @@ msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktue
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr ""
|
msgstr "Dette vil permanent slette alle poster fra databasen."
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
@@ -1065,21 +1164,21 @@ msgstr "Skift tema"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Nøgle"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens & Fingerprints"
|
msgid "Tokens & Fingerprints"
|
||||||
msgstr ""
|
msgstr "Nøgler & fingeraftryk"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
msgid "Tokens allow agents to connect and register. Fingerprints are stable identifiers unique to each system, set on first connection."
|
||||||
msgstr ""
|
msgstr "Nøgler tillader agenter at oprette forbindelse og registrere. Fingeraftryk er stabile identifikatorer unikke for hvert system, indstillet ved første forbindelse."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr ""
|
msgstr "Nøgler og fingeraftryk bruges til at godkende WebSocket-forbindelser til hubben."
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
@@ -1091,15 +1190,15 @@ msgstr "Samlet sendt data for hver interface"
|
|||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 1 minute load average exceeds a threshold"
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Udløser når 1 minut belastning gennemsnit overstiger en tærskel"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 15 minute load average exceeds a threshold"
|
msgid "Triggers when 15 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Udløser når 15 minut belastning gennemsnit overstiger en tærskel"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 5 minute load average exceeds a threshold"
|
msgid "Triggers when 5 minute load average exceeds a threshold"
|
||||||
msgstr ""
|
msgstr "Udløser når 5 minut belastning gennemsnit overstiger en tærskel"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when any sensor exceeds a threshold"
|
msgid "Triggers when any sensor exceeds a threshold"
|
||||||
@@ -1125,15 +1224,19 @@ msgstr "Udløser når status skifter mellem op og ned"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Udløser når brugen af en disk overstiger en tærskel"
|
msgstr "Udløser når brugen af en disk overstiger en tærskel"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Type"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
msgstr ""
|
msgstr "Enhedspræferencer"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr ""
|
msgstr "Universalnøgle"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
@@ -1148,11 +1251,15 @@ msgstr "Oppe"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr ""
|
msgstr "Oppe ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Opdateret"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Upload"
|
msgstr "Overfør"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
@@ -1181,7 +1288,7 @@ msgstr "Brugere"
|
|||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr ""
|
msgstr "Værdi"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "View"
|
msgid "View"
|
||||||
@@ -1193,7 +1300,7 @@ msgstr "Se mere"
|
|||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "View your 200 most recent alerts."
|
msgid "View your 200 most recent alerts."
|
||||||
msgstr ""
|
msgstr "Se dine 200 nyeste alarmer."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Visible Fields"
|
msgid "Visible Fields"
|
||||||
@@ -1221,7 +1328,7 @@ msgstr "Webhook / Push notifikationer"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
|
msgid "When enabled, this token allows agents to self-register without prior system creation. Expires after one hour or on hub restart."
|
||||||
msgstr ""
|
msgstr "Når aktiveret tillader denne nøgle agenter at selvregistrere uden forudgående systemoprettelse. Udløber efter en time eller ved hub-genstart."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ msgstr ""
|
|||||||
"Language: de\n"
|
"Language: de\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-10-25 21:09\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: de\n"
|
"X-Crowdin-Language: de\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -48,6 +48,10 @@ msgstr "1 Stunde"
|
|||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr "1 Min"
|
msgstr "1 Min"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "1 minute"
|
||||||
|
msgstr "1 Minute"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
msgstr "1 Woche"
|
msgstr "1 Woche"
|
||||||
@@ -85,7 +89,7 @@ msgstr "Aktionen"
|
|||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktiv"
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktive Warnungen"
|
msgstr "Aktive Warnungen"
|
||||||
|
|
||||||
@@ -122,14 +126,22 @@ msgstr "Agent"
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Alert History"
|
msgid "Alert History"
|
||||||
msgstr "Alarm-Verlauf"
|
msgstr "Warnungsverlauf"
|
||||||
|
|
||||||
#: src/components/alerts/alert-button.tsx
|
#: src/components/alerts/alert-button.tsx
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Alerts"
|
msgid "Alerts"
|
||||||
msgstr "Warnungen"
|
msgstr "Warnungen"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/containers.tsx
|
||||||
|
msgid "All Containers"
|
||||||
|
msgstr "Alle Container"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/routes/home.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -222,6 +234,10 @@ msgstr "Cache / Puffer"
|
|||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Abbrechen"
|
msgstr "Abbrechen"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Kapazität"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Vorsicht - potenzieller Datenverlust"
|
msgstr "Vorsicht - potenzieller Datenverlust"
|
||||||
@@ -263,6 +279,14 @@ msgstr "Überprüfe die Protokolle für weitere Details."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Überprüfe deinen Benachrichtigungsdienst"
|
msgstr "Überprüfe deinen Benachrichtigungsdienst"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Click on a container to view more information."
|
||||||
|
msgstr "Klicke auf einen Container, um weitere Informationen zu sehen."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Klicke auf ein Gerät, um weitere Informationen zu sehen."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "Klicke auf ein System, um weitere Informationen zu sehen."
|
msgstr "Klicke auf ein System, um weitere Informationen zu sehen."
|
||||||
@@ -285,7 +309,7 @@ msgstr "Konfiguriere, wie du Warnbenachrichtigungen erhältst."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Passwort bestätigen"
|
msgstr "Passwort bestätigen"
|
||||||
|
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "Verbindung unterbrochen"
|
msgstr "Verbindung unterbrochen"
|
||||||
|
|
||||||
@@ -334,16 +358,17 @@ msgstr "Text kopieren"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
msgid "Copy the installation command for the agent below, or register agents automatically with a <0>universal token</0>."
|
||||||
msgstr "Kopieren Sie den Installationsbefehl für den Agent unten oder registrieren Sie Agents automatisch mit einem <0>universellen Token</0>."
|
msgstr "Kopiere den Installationsbefehl für den Agent unten oder registriere Agents automatisch mit einem <0>universellen Token</0>."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
msgid "Copy the<0>docker-compose.yml</0> content for the agent below, or register agents automatically with a <1>universal token</1>."
|
||||||
msgstr "Kopieren Sie den<0>docker-compose.yml</0> Inhalt für den Agent unten oder registrieren Sie Agents automatisch mit einem <1>universellen Token</1>."
|
msgstr "Kopiere den<0>docker-compose.yml</0> Inhalt für den Agent unten oder registriere Agents automatisch mit einem <1>universellen Token</1>."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Copy YAML"
|
msgid "Copy YAML"
|
||||||
msgstr "YAML kopieren"
|
msgstr "YAML kopieren"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr "CPU"
|
||||||
@@ -380,8 +405,12 @@ msgstr "Kumulativer Upload"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Aktueller Zustand"
|
msgstr "Aktueller Zustand"
|
||||||
|
|
||||||
|
#. Power Cycles
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Cycles"
|
||||||
|
msgstr "Zyklen"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
|
||||||
msgid "Dashboard"
|
msgid "Dashboard"
|
||||||
msgstr "Dashboard"
|
msgstr "Dashboard"
|
||||||
|
|
||||||
@@ -398,6 +427,14 @@ msgstr "Löschen"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Fingerabdruck löschen"
|
msgstr "Fingerabdruck löschen"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Detail"
|
||||||
|
msgstr "Details"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Gerät"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -455,7 +492,7 @@ msgstr "Offline ({downSystemsLength})"
|
|||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Download"
|
msgid "Download"
|
||||||
msgstr "Download"
|
msgstr "Herunterladen"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Duration"
|
msgid "Duration"
|
||||||
@@ -504,7 +541,7 @@ msgstr "Fehler"
|
|||||||
#. placeholder {0}: alert.value
|
#. placeholder {0}: alert.value
|
||||||
#. placeholder {1}: info.unit
|
#. placeholder {1}: info.unit
|
||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}"
|
msgstr "Überschreitet {0}{1} in den letzten {2, plural, one {# Minute} other {# Minuten}}"
|
||||||
|
|
||||||
@@ -528,6 +565,10 @@ msgstr "Exportiere die aktuelle Systemkonfiguration."
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "Fahrenheit (°F)"
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Fehlgeschlagene Attribute:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Authentifizierung fehlgeschlagen"
|
msgstr "Authentifizierung fehlgeschlagen"
|
||||||
@@ -545,8 +586,10 @@ msgstr "Testbenachrichtigung konnte nicht gesendet werden"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Warnung konnte nicht aktualisiert werden"
|
msgstr "Warnung konnte nicht aktualisiert werden"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filter..."
|
msgstr "Filter..."
|
||||||
@@ -555,6 +598,10 @@ msgstr "Filter..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "Fingerabdruck"
|
msgstr "Fingerabdruck"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "Firmware"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "Für <0>{min}</0> {min, plural, one {Minute} other {Minuten}}"
|
msgstr "Für <0>{min}</0> {min, plural, one {Minute} other {Minuten}}"
|
||||||
@@ -592,6 +639,10 @@ msgstr "GPU-Leistungsaufnahme"
|
|||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Raster"
|
msgstr "Raster"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Health"
|
||||||
|
msgstr "Gesundheit"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgctxt "Button to copy install command"
|
msgctxt "Button to copy install command"
|
||||||
@@ -611,6 +662,11 @@ msgstr "Untätig"
|
|||||||
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
msgid "If you've lost the password to your admin account, you may reset it using the following command."
|
||||||
msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst du es mit dem folgenden Befehl zurücksetzen."
|
msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst du es mit dem folgenden Befehl zurücksetzen."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgctxt "Docker image"
|
||||||
|
msgid "Image"
|
||||||
|
msgstr "Image"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Ungültige E-Mail-Adresse."
|
msgstr "Ungültige E-Mail-Adresse."
|
||||||
@@ -663,6 +719,7 @@ msgid "Login attempt failed"
|
|||||||
msgstr "Anmeldeversuch fehlgeschlagen"
|
msgstr "Anmeldeversuch fehlgeschlagen"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Logs"
|
msgid "Logs"
|
||||||
msgstr "Protokolle"
|
msgstr "Protokolle"
|
||||||
@@ -685,6 +742,7 @@ msgstr "Anleitung zur manuellen Einrichtung"
|
|||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr "Max 1 Min"
|
msgstr "Max 1 Min"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Arbeitsspeicher"
|
msgstr "Arbeitsspeicher"
|
||||||
@@ -698,11 +756,17 @@ msgstr "Arbeitsspeichernutzung"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Arbeitsspeichernutzung der Docker-Container"
|
msgstr "Arbeitsspeichernutzung der Docker-Container"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Modell"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Net"
|
msgid "Net"
|
||||||
msgstr "Netz"
|
msgstr "Netz"
|
||||||
@@ -727,10 +791,18 @@ msgstr "Netzwerkeinheit"
|
|||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Keine Ergebnisse gefunden."
|
msgstr "Keine Ergebnisse gefunden."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "Keine Ergebnisse."
|
msgstr "Keine Ergebnisse."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Für dieses Gerät sind keine S.M.A.R.T.-Attribute verfügbar."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -768,6 +840,7 @@ msgstr "Oder fortfahren mit"
|
|||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Bestehende Warnungen überschreiben"
|
msgstr "Bestehende Warnungen überschreiben"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr "Seite"
|
msgstr "Seite"
|
||||||
@@ -848,6 +921,11 @@ msgstr "Bitte melde dich bei deinem Konto an"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Port"
|
msgstr "Port"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Eingeschaltet"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -860,7 +938,7 @@ msgstr "Bevorzugte Sprache"
|
|||||||
#. Use 'Key' if your language requires many more characters
|
#. Use 'Key' if your language requires many more characters
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Schlüssel"
|
msgstr "Öffentlicher Schlüssel"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -872,6 +950,11 @@ msgstr "Lesen"
|
|||||||
msgid "Received"
|
msgid "Received"
|
||||||
msgstr "Empfangen"
|
msgstr "Empfangen"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
msgid "Refresh"
|
||||||
|
msgstr "Aktualisieren"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Einmalpasswort anfordern"
|
msgstr "Einmalpasswort anfordern"
|
||||||
@@ -902,6 +985,14 @@ msgstr "Token rotieren"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "Zeilen pro Seite"
|
msgstr "Zeilen pro Seite"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T.-Details"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T.-Selbsttest"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
|
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
|
||||||
@@ -913,7 +1004,7 @@ msgstr "Einstellungen speichern"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr "System sichern"
|
msgstr "System speichern"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
@@ -931,6 +1022,10 @@ msgstr "Siehe <0>Benachrichtigungseinstellungen</0>, um zu konfigurieren, wie du
|
|||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Gesendet"
|
msgstr "Gesendet"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Seriennummer"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen."
|
msgstr "Prozentuale Schwellenwerte für Zählerfarben festlegen."
|
||||||
@@ -963,6 +1058,8 @@ msgstr "Sortieren nach"
|
|||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "Status"
|
msgstr "Status"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
@@ -977,6 +1074,7 @@ msgid "Swap Usage"
|
|||||||
msgstr "Swap-Nutzung"
|
msgstr "Swap-Nutzung"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -1000,6 +1098,7 @@ msgid "Table"
|
|||||||
msgstr "Tabelle"
|
msgstr "Tabelle"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Temperatur"
|
msgstr "Temperatur"
|
||||||
@@ -1083,11 +1182,11 @@ msgstr "Tokens und Fingerabdrücke werden verwendet, um WebSocket-Verbindungen z
|
|||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Gesamtdatenmenge für jede Schnittstelle empfangen"
|
msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Gesamtdatenmenge für jede Schnittstelle gesendet"
|
msgstr "Gesendete Gesamtdatenmenge je Schnittstelle"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 1 minute load average exceeds a threshold"
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
@@ -1107,7 +1206,7 @@ msgstr "Löst aus, wenn ein Sensor einen Schwellenwert überschreitet"
|
|||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when combined up/down exceeds a threshold"
|
msgid "Triggers when combined up/down exceeds a threshold"
|
||||||
msgstr "Löst aus, wenn die kombinierte Auf-/Abwärtsbewegung einen Schwellenwert überschreitet"
|
msgstr "Löst aus, wenn die kombinierte Up- und Downloadrate einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
msgid "Triggers when CPU usage exceeds a threshold"
|
||||||
@@ -1125,6 +1224,10 @@ msgstr "Löst aus, wenn der Status zwischen online und offline wechselt"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
|
msgstr "Löst aus, wenn die Nutzung einer Festplatte einen Schwellenwert überschreitet"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Typ"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1150,9 +1253,13 @@ msgstr "aktiv"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "aktiv ({upSystemsLength})"
|
msgstr "aktiv ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
msgid "Updated"
|
||||||
|
msgstr "Aktualisiert"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Upload"
|
msgstr "Hochladen"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
|
|||||||
1296
internal/site/src/locales/el/el.po
Normal file
1296
internal/site/src/locales/el/el.po
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user