mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-25 06:56:17 +01:00
Compare commits
1 Commits
split-syst
...
total-line
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23fff31a05 |
105
.github/workflows/docker-images.yml
vendored
105
.github/workflows/docker-images.yml
vendored
@@ -10,141 +10,67 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 5
|
|
||||||
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:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
context: ./
|
||||||
|
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=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# 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' }}
|
|
||||||
|
|
||||||
# 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=raw,value=latest
|
|
||||||
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
|
|
||||||
|
|
||||||
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
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' }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -174,7 +100,12 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
tags: ${{ matrix.tags }}
|
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
|
||||||
@@ -192,7 +123,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: ./
|
context: "${{ matrix.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] != '' }}
|
||||||
|
|||||||
17
.github/workflows/inactivity-actions.yml
vendored
17
.github/workflows/inactivity-actions.yml
vendored
@@ -10,25 +10,12 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lock-inactive:
|
|
||||||
name: Lock Inactive Issues
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
steps:
|
|
||||||
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
|
||||||
id: lock
|
|
||||||
with:
|
|
||||||
days-inactive-issues: 14
|
|
||||||
lock-reason-issues: ""
|
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-inactive-prs: 36524
|
|
||||||
lock-reason-prs: ""
|
|
||||||
|
|
||||||
close-stale:
|
close-stale:
|
||||||
name: Close Stale Issues
|
name: Close Stale Issues
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Close Stale Issues
|
- name: Close Stale Issues
|
||||||
uses: actions/stale@v10
|
uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -45,8 +32,6 @@ jobs:
|
|||||||
# Timing
|
# Timing
|
||||||
days-before-issue-stale: 14
|
days-before-issue-stale: 14
|
||||||
days-before-issue-close: 7
|
days-before-issue-close: 7
|
||||||
# Action can not skip PRs, set it to 100 years to cover it.
|
|
||||||
days-before-pr-stale: 36524
|
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ project_name: beszel
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate -run fetchsmartctl ./agent
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
@@ -16,21 +15,10 @@ builds:
|
|||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
- windows
|
|
||||||
- freebsd
|
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
ignore:
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm64
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm64
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
@@ -97,9 +85,6 @@ archives:
|
|||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats: [zip]
|
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -7,7 +7,7 @@ SKIP_WEB ?= false
|
|||||||
# Set executable extension based on target OS
|
# Set executable extension based on target OS
|
||||||
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
|
||||||
|
|
||||||
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -46,14 +46,8 @@ build-dotnet-conditional:
|
|||||||
fi; \
|
fi; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download smartctl.exe at build time for Windows (skips if already present)
|
|
||||||
fetch-smartctl-conditional:
|
|
||||||
@if [ "$(OS)" = "windows" ]; then \
|
|
||||||
go generate -run fetchsmartctl ./agent; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update build-agent to include conditional .NET build
|
# Update build-agent to include conditional .NET build
|
||||||
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
build-agent: tidy build-dotnet-conditional
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
||||||
|
|
||||||
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ 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/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"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"
|
||||||
@@ -31,15 +29,12 @@ type Agent struct {
|
|||||||
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
|
||||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||||
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
|
||||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
systemInfo system.Info // Host system info
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -47,8 +42,6 @@ type Agent struct {
|
|||||||
server *ssh.Server // SSH server
|
server *ssh.Server // SSH server
|
||||||
dataDir string // Directory for persisting data
|
dataDir string // Directory for persisting data
|
||||||
keys []gossh.PublicKey // SSH public keys
|
keys []gossh.PublicKey // SSH public keys
|
||||||
smartManager *SmartManager // Manages SMART data
|
|
||||||
systemdManager *systemdManager // Manages systemd services
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -74,16 +67,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
|
|
||||||
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
|
||||||
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
|
||||||
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
|
||||||
agent.diskUsageCacheDuration = duration
|
|
||||||
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
|
||||||
} else {
|
|
||||||
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
@@ -99,11 +82,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize docker manager
|
|
||||||
agent.dockerManager = newDockerManager()
|
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshStaticInfo()
|
agent.initializeSystemInfo()
|
||||||
|
|
||||||
// initialize connection manager
|
// initialize connection manager
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
@@ -117,25 +97,19 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize net io stats
|
// initialize net io stats
|
||||||
agent.initializeNetIoStats()
|
agent.initializeNetIoStats()
|
||||||
|
|
||||||
agent.systemdManager, err = newSystemdManager()
|
// initialize docker manager
|
||||||
if err != nil {
|
agent.dockerManager = newDockerManager(agent)
|
||||||
slog.Debug("Systemd", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("SMART", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
agent.gpuManager, err = NewGPUManager()
|
if gm, err := NewGPUManager(); err != nil {
|
||||||
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(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
slog.Debug("Stats", "data", agent.gatherStats(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -150,11 +124,10 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
cacheTimeMs := options.CacheTimeMs
|
|
||||||
data, isCached := a.cache.Get(cacheTimeMs)
|
data, isCached := a.cache.Get(cacheTimeMs)
|
||||||
if isCached {
|
if isCached {
|
||||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||||
@@ -165,12 +138,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Stats: a.getSystemStats(cacheTimeMs),
|
Stats: a.getSystemStats(cacheTimeMs),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include static info only when requested
|
|
||||||
if options.IncludeDetails {
|
|
||||||
data.Details = &a.systemDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -182,20 +149,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
|
||||||
if totalCount > 0 {
|
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
|
||||||
data.Info.Services = []uint16{totalCount, numFailed}
|
|
||||||
}
|
|
||||||
if a.systemdManager.hasFreshStats {
|
|
||||||
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
@@ -204,11 +158,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
// Add percentages to Info struct for dashboard
|
|
||||||
if stats.DiskTotal > 0 {
|
|
||||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
|
||||||
data.Info.ExtraFsPct[key] = pct
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
@@ -234,7 +183,7 @@ func (a *Agent) getFingerprint() string {
|
|||||||
// if no fingerprint is found, generate one
|
// if no fingerprint is found, generate one
|
||||||
fingerprint, err := host.HostID()
|
fingerprint, err := host.HostID()
|
||||||
if err != nil || fingerprint == "" {
|
if err != nil || fingerprint == "" {
|
||||||
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// hash fingerprint
|
// hash fingerprint
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
|
|||||||
DiskTotal: 100000,
|
DiskTotal: 100000,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
AgentVersion: "0.12.0",
|
Hostname: "test-host",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
{
|
||||||
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
|
|||||||
Mem: 16384,
|
Mem: 16384,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
AgentVersion: "0.12.0",
|
Hostname: "test-host-2",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
|
|||||||
Mem: 32768,
|
Mem: 32768,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
AgentVersion: "0.12.0",
|
Hostname: "updated-host",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ package battery
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var systemHasBattery = false
|
||||||
systemHasBattery = false
|
var haveCheckedBattery = false
|
||||||
haveCheckedBattery = false
|
|
||||||
)
|
|
||||||
|
|
||||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||||
func HasReadableBattery() bool {
|
func HasReadableBattery() bool {
|
||||||
@@ -22,13 +19,8 @@ func HasReadableBattery() bool {
|
|||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
batteries, err := battery.GetAll()
|
bat, err := battery.Get(0)
|
||||||
for _, bat := range batteries {
|
systemHasBattery = err == nil && bat != nil && bat.Design != 0 && bat.Full != 0
|
||||||
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
|
||||||
systemHasBattery = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !systemHasBattery {
|
if !systemHasBattery {
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
@@ -36,49 +28,25 @@ 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 !HasReadableBattery() {
|
if !systemHasBattery {
|
||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
batteries, err := battery.GetAll()
|
batteries, err := battery.GetAll()
|
||||||
// we'll handle errors later by skipping batteries with errors, rather
|
if err != nil || len(batteries) == 0 {
|
||||||
// than skipping everything because of the presence of some errors.
|
return batteryPercent, batteryState, err
|
||||||
if len(batteries) == 0 {
|
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
totalCapacity := float64(0)
|
totalCapacity := float64(0)
|
||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
errs, partialErrs := err.(battery.Errors)
|
for _, bat := range batteries {
|
||||||
|
if bat.Design != 0 {
|
||||||
batteryState = math.MaxUint8
|
totalCapacity += bat.Design
|
||||||
|
} else {
|
||||||
for i, bat := range batteries {
|
totalCapacity += bat.Full
|
||||||
if partialErrs && errs[i] != nil {
|
|
||||||
// if there were some errors, like missing data, skip it
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if bat == nil || bat.Full == 0 {
|
|
||||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
|
||||||
// we can't guarantee that, so don't skip based on charge.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalCapacity += bat.Full
|
|
||||||
totalCharge += bat.Current
|
totalCharge += bat.Current
|
||||||
if bat.State.Raw >= 0 {
|
|
||||||
batteryState = uint8(bat.State.Raw)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
|
||||||
// https://github.com/distatus/battery/issues/34
|
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
|
||||||
// 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)
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,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/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -201,7 +199,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||||
response.Hostname = client.agent.systemDetails.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)
|
||||||
}
|
}
|
||||||
@@ -273,12 +271,6 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case *common.FingerprintResponse:
|
case *common.FingerprintResponse:
|
||||||
response.Fingerprint = v
|
response.Fingerprint = v
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
// case []byte:
|
// case []byte:
|
||||||
// response.RawBytes = v
|
// response.RawBytes = v
|
||||||
// case string:
|
// case string:
|
||||||
|
|||||||
92
agent/cpu.go
92
agent/cpu.go
@@ -4,12 +4,10 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
|
||||||
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
|
|
||||||
|
|
||||||
// init initializes the CPU monitoring by storing the initial CPU times
|
// init initializes the CPU monitoring by storing the initial CPU times
|
||||||
// for the default 60-second cache interval.
|
// for the default 60-second cache interval.
|
||||||
@@ -17,92 +15,23 @@ func init() {
|
|||||||
if times, err := cpu.Times(false); err == nil {
|
if times, err := cpu.Times(false); err == nil {
|
||||||
lastCpuTimes[60000] = times[0]
|
lastCpuTimes[60000] = times[0]
|
||||||
}
|
}
|
||||||
if perCoreTimes, err := cpu.Times(true); err == nil {
|
|
||||||
lastPerCoreCpuTimes[60000] = perCoreTimes
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CpuMetrics contains detailed CPU usage breakdown
|
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
|
||||||
type CpuMetrics struct {
|
// It uses the specified cache time interval to determine the time window for calculation.
|
||||||
Total float64
|
// Returns the CPU usage percentage (0-100) and any error encountered.
|
||||||
User float64
|
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
|
||||||
System float64
|
|
||||||
Iowait float64
|
|
||||||
Steal float64
|
|
||||||
Idle float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
|
||||||
// It returns percentages for total, user, system, iowait, and steal time.
|
|
||||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
|
||||||
times, err := cpu.Times(false)
|
times, err := cpu.Times(false)
|
||||||
if err != nil || len(times) == 0 {
|
if err != nil || len(times) == 0 {
|
||||||
return CpuMetrics{}, err
|
return 0, err
|
||||||
}
|
}
|
||||||
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
// if cacheTimeMs is not in lastCpuTimes, use 60000 as fallback lastCpuTime
|
||||||
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
if _, ok := lastCpuTimes[cacheTimeMs]; !ok {
|
||||||
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
lastCpuTimes[cacheTimeMs] = lastCpuTimes[60000]
|
||||||
}
|
}
|
||||||
|
delta := calculateBusy(lastCpuTimes[cacheTimeMs], times[0])
|
||||||
t1 := lastCpuTimes[cacheTimeMs]
|
|
||||||
t2 := times[0]
|
|
||||||
|
|
||||||
t1All, _ := getAllBusy(t1)
|
|
||||||
t2All, _ := getAllBusy(t2)
|
|
||||||
|
|
||||||
totalDelta := t2All - t1All
|
|
||||||
if totalDelta <= 0 {
|
|
||||||
return CpuMetrics{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics := CpuMetrics{
|
|
||||||
Total: calculateBusy(t1, t2),
|
|
||||||
User: clampPercent((t2.User - t1.User) / totalDelta * 100),
|
|
||||||
System: clampPercent((t2.System - t1.System) / totalDelta * 100),
|
|
||||||
Iowait: clampPercent((t2.Iowait - t1.Iowait) / totalDelta * 100),
|
|
||||||
Steal: clampPercent((t2.Steal - t1.Steal) / totalDelta * 100),
|
|
||||||
Idle: clampPercent((t2.Idle - t1.Idle) / totalDelta * 100),
|
|
||||||
}
|
|
||||||
|
|
||||||
lastCpuTimes[cacheTimeMs] = times[0]
|
lastCpuTimes[cacheTimeMs] = times[0]
|
||||||
return metrics, nil
|
return delta, nil
|
||||||
}
|
|
||||||
|
|
||||||
// clampPercent ensures the percentage is between 0 and 100
|
|
||||||
func clampPercent(value float64) float64 {
|
|
||||||
return math.Min(100, math.Max(0, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
|
|
||||||
// It uses cached previous measurements for the provided cache interval.
|
|
||||||
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
|
||||||
perCoreTimes, err := cpu.Times(true)
|
|
||||||
if err != nil || len(perCoreTimes) == 0 {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize cache if needed
|
|
||||||
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
|
||||||
|
|
||||||
// Limit to the number of cores available in both samples
|
|
||||||
length := len(perCoreTimes)
|
|
||||||
if len(lastTimes) < length {
|
|
||||||
length = len(lastTimes)
|
|
||||||
}
|
|
||||||
|
|
||||||
usage := make([]uint8, length)
|
|
||||||
for i := 0; i < length; i++ {
|
|
||||||
t1 := lastTimes[i]
|
|
||||||
t2 := perCoreTimes[i]
|
|
||||||
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
|
|
||||||
return usage, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||||
@@ -112,10 +41,13 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|||||||
t1All, t1Busy := getAllBusy(t1)
|
t1All, t1Busy := getAllBusy(t1)
|
||||||
t2All, t2Busy := getAllBusy(t2)
|
t2All, t2Busy := getAllBusy(t2)
|
||||||
|
|
||||||
if t2All <= t1All || t2Busy <= t1Busy {
|
if t2Busy <= t1Busy {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
|
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.
|
// getAllBusy calculates the total CPU time and busy CPU time from CPU times statistics.
|
||||||
|
|||||||
@@ -37,16 +37,6 @@ 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()
|
||||||
@@ -63,15 +53,6 @@ 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 {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
filesystem, _ := GetEnv("FILESYSTEM")
|
filesystem, _ := GetEnv("FILESYSTEM")
|
||||||
efPath := "/extra-filesystems"
|
efPath := "/extra-filesystems"
|
||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.Partitions(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,13 +38,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
slog.Debug("Disk", "partitions", partitions)
|
slog.Debug("Disk", "partitions", partitions)
|
||||||
|
|
||||||
// trim trailing backslash for Windows devices (#1361)
|
|
||||||
if isWindows {
|
|
||||||
for i, p := range partitions {
|
|
||||||
partitions[i].Device = strings.TrimSuffix(p.Device, "\\")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ioContext := context.WithValue(a.sensorsContext,
|
// ioContext := context.WithValue(a.sensorsContext,
|
||||||
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
// common.EnvKey, common.EnvMap{common.HostProcEnvKey: "/tmp/testproc"},
|
||||||
// )
|
// )
|
||||||
@@ -60,7 +52,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// 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, customName ...string) {
|
addFsStat := func(device, mountpoint string, root bool, customName ...string) {
|
||||||
var key string
|
var key string
|
||||||
if isWindows {
|
if runtime.GOOS == "windows" {
|
||||||
key = device
|
key = device
|
||||||
} else {
|
} else {
|
||||||
key = filepath.Base(device)
|
key = filepath.Base(device)
|
||||||
@@ -95,9 +87,6 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the appropriate root mount point for this system
|
|
||||||
rootMountPoint := a.getRootMountPoint()
|
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
if filesystem != "" {
|
if filesystem != "" {
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
@@ -141,7 +130,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
// Binary root fallback or docker root fallback
|
// Binary root fallback or docker root fallback
|
||||||
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
if match {
|
if match {
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
@@ -177,8 +166,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
@@ -225,19 +214,8 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
|
|
||||||
// Updates disk usage statistics for all monitored filesystems
|
// Updates disk usage statistics for all monitored filesystems
|
||||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||||
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
|
|
||||||
// Root filesystem is always updated since it can't be sleeping while the agent runs.
|
|
||||||
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
|
|
||||||
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
|
|
||||||
!a.lastDiskUsageUpdate.IsZero() &&
|
|
||||||
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
for _, stats := range a.fsStats {
|
||||||
// Skip non-root filesystems if caching is active
|
|
||||||
if cacheExtraFs && !stats.Root {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
@@ -255,11 +233,6 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.TotalWrite = 0
|
stats.TotalWrite = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the last disk usage update time when we've collected extra filesystems
|
|
||||||
if !cacheExtraFs {
|
|
||||||
a.lastDiskUsageUpdate = time.Now()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates disk I/O statistics for all monitored filesystems
|
// Updates disk I/O statistics for all monitored filesystems
|
||||||
@@ -331,32 +304,3 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system
|
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
|
||||||
func (a *Agent) getRootMountPoint() string {
|
|
||||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
|
||||||
content := string(osReleaseContent)
|
|
||||||
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
|
||||||
strings.Contains(content, "coreos") ||
|
|
||||||
strings.Contains(content, "flatcar") ||
|
|
||||||
strings.Contains(content, "rhel-atomic") ||
|
|
||||||
strings.Contains(content, "centos-atomic") {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
|
||||||
if _, err := os.Stat("/run/ostree"); err == nil {
|
|
||||||
// Verify that /sysroot exists before returning it
|
|
||||||
if _, err := os.Stat("/sysroot"); err == nil {
|
|
||||||
return "/sysroot"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
@@ -234,86 +233,3 @@ func TestExtraFsKeyGeneration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiskUsageCaching(t *testing.T) {
|
|
||||||
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 0, // caching disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// Both should be updated (non-zero values from disk.Usage)
|
|
||||||
// Root stats should be populated in systemStats
|
|
||||||
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
|
||||||
"lastDiskUsageUpdate should be set when caching is disabled")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Hour,
|
|
||||||
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store original extra fs values
|
|
||||||
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
|
||||||
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// Root should be updated (systemStats populated from disk.Usage call)
|
|
||||||
// We can't easily check if disk.Usage was called, but we verify the flow works
|
|
||||||
|
|
||||||
// Extra filesystem should retain cached values (not reset)
|
|
||||||
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
|
||||||
"extra filesystem DiskTotal should be unchanged when cached")
|
|
||||||
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
|
||||||
"extra filesystem DiskUsed should be unchanged when cached")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Hour,
|
|
||||||
// lastDiskUsageUpdate is zero (first call)
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// After first call, lastDiskUsageUpdate should be set
|
|
||||||
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
|
||||||
"lastDiskUsageUpdate should be set after first call")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
|
||||||
agent := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{
|
|
||||||
"sda": {Root: true, Mountpoint: "/"},
|
|
||||||
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
|
||||||
},
|
|
||||||
diskUsageCacheDuration: 1 * time.Millisecond,
|
|
||||||
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats system.Stats
|
|
||||||
agent.updateDiskUsage(&stats)
|
|
||||||
|
|
||||||
// lastDiskUsageUpdate should be refreshed since cache expired
|
|
||||||
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
|
||||||
"lastDiskUsageUpdate should be refreshed when cache expires")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
227
agent/docker.go
227
agent/docker.go
@@ -3,18 +3,13 @@ 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"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,10 +20,6 @@ import (
|
|||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
|
||||||
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
|
||||||
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Docker API timeout in milliseconds
|
// Docker API timeout in milliseconds
|
||||||
dockerTimeoutMs = 2100
|
dockerTimeoutMs = 2100
|
||||||
@@ -36,14 +27,6 @@ const (
|
|||||||
maxNetworkSpeedBps uint64 = 5e9
|
maxNetworkSpeedBps uint64 = 5e9
|
||||||
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
// Maximum conceivable memory usage of a container (100TB) to detect bad memory stats
|
||||||
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
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 {
|
||||||
@@ -59,8 +42,6 @@ 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
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -102,19 +83,6 @@ func (d *dockerManager) dequeue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldExcludeContainer checks if a container name matches any exclusion pattern
|
|
||||||
func (dm *dockerManager) shouldExcludeContainer(name string) bool {
|
|
||||||
if len(dm.excludeContainers) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, pattern := range dm.excludeContainers {
|
|
||||||
if match, _ := path.Match(pattern, name); match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns stats for all running containers with cache-time-aware delta tracking
|
// Returns stats for all running containers with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*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")
|
||||||
@@ -142,13 +110,6 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
|
|
||||||
for _, ctr := range dm.apiContainerList {
|
for _, ctr := range dm.apiContainerList {
|
||||||
ctr.IdShort = ctr.Id[:12]
|
ctr.IdShort = ctr.Id[:12]
|
||||||
|
|
||||||
// Skip this container if it matches the exclusion pattern
|
|
||||||
if dm.shouldExcludeContainer(ctr.Names[0][1:]) {
|
|
||||||
slog.Debug("Excluding container", "name", ctr.Names[0][1:])
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
// note: can't use Created field because it's not updated on restart
|
// note: can't use Created field because it's not updated on restart
|
||||||
@@ -340,46 +301,11 @@ func updateContainerStatsValues(stats *container.Stats, cpuPct float64, usedMemo
|
|||||||
stats.PrevReadTime = readTime
|
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
|
// Updates stats for individual container with cache-time-aware delta tracking
|
||||||
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeMs uint16) error {
|
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(fmt.Sprintf("http://localhost/containers/%s/stats?stream=0&one-shot=1", ctr.IdShort))
|
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -390,16 +316,10 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
|||||||
// 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, Id: ctr.IdShort, Image: ctr.Image}
|
stats = &container.Stats{Name: name}
|
||||||
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
|
||||||
@@ -479,7 +399,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager() *dockerManager {
|
func newDockerManager(a *Agent) *dockerManager {
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -531,19 +451,6 @@ func newDockerManager() *dockerManager {
|
|||||||
userAgent: "Docker-Client/",
|
userAgent: "Docker-Client/",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
|
||||||
var excludeContainers []string
|
|
||||||
if excludeStr, set := GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
|
||||||
parts := strings.SplitSeq(excludeStr, ",")
|
|
||||||
for part := range parts {
|
|
||||||
trimmed := strings.TrimSpace(part)
|
|
||||||
if trimmed != "" {
|
|
||||||
excludeContainers = append(excludeContainers, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slog.Info("EXCLUDE_CONTAINERS", "patterns", excludeContainers)
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
@@ -553,7 +460,6 @@ func newDockerManager() *dockerManager {
|
|||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
apiStats: &container.ApiStats{},
|
apiStats: &container.ApiStats{},
|
||||||
excludeContainers: excludeContainers,
|
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
@@ -565,7 +471,7 @@ func newDockerManager() *dockerManager {
|
|||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
if strings.Contains(dockerHost, "podman") {
|
if strings.Contains(dockerHost, "podman") {
|
||||||
manager.usingPodman = true
|
a.systemInfo.Podman = true
|
||||||
manager.goodDockerVersion = true
|
manager.goodDockerVersion = true
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
@@ -642,128 +548,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip ANSI escape sequences from logs for clean display in web UI
|
|
||||||
logs := builder.String()
|
|
||||||
if strings.Contains(logs, "\x1b") {
|
|
||||||
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
|
||||||
}
|
|
||||||
return logs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
|
||||||
const headerSize = 8
|
|
||||||
var header [headerSize]byte
|
|
||||||
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.CopyN(io.Discard, reader, int64(frameLen))
|
|
||||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := io.CopyN(builder, reader, int64(frameLen))
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
totalBytesRead += int(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHostInfo fetches the system info from Docker
|
|
||||||
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|
||||||
resp, err := dm.client.Get("http://localhost/info")
|
|
||||||
if err != nil {
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
||||||
slog.Error("Failed to decode Docker version response", "error", err)
|
|
||||||
return info, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dm *dockerManager) IsPodman() bool {
|
|
||||||
return dm.usingPodman
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -802,24 +800,6 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHostInfo(t *testing.T) {
|
|
||||||
data, err := os.ReadFile("test-data/system_info.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var info container.HostInfo
|
|
||||||
err = json.Unmarshal(data, &info)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
|
|
||||||
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
|
|
||||||
// assert.Equal(t, "24.04", info.OSVersion)
|
|
||||||
// assert.Equal(t, "linux", info.OSType)
|
|
||||||
// assert.Equal(t, "x86_64", info.Architecture)
|
|
||||||
assert.EqualValues(t, 4, info.NCPU)
|
|
||||||
assert.EqualValues(t, 2095882240, info.MemTotal)
|
|
||||||
// assert.Equal(t, "27.0.1", info.ServerVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
||||||
// Test that different cache times have separate DeltaTracker instances
|
// Test that different cache times have separate DeltaTracker instances
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
@@ -878,61 +858,11 @@ func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
assert.Equal(t, uint64(200000), recvTracker2.Delta(ctr.IdShort))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseDockerStatus(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expectedStatus string
|
|
||||||
expectedHealth container.DockerHealth
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "status with About an removed",
|
|
||||||
input: "Up About an hour (healthy)",
|
|
||||||
expectedStatus: "Up an hour",
|
|
||||||
expectedHealth: container.DockerHealthHealthy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status without About an unchanged",
|
|
||||||
input: "Up 2 hours (healthy)",
|
|
||||||
expectedStatus: "Up 2 hours",
|
|
||||||
expectedHealth: container.DockerHealthHealthy,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status with About and no parentheses",
|
|
||||||
input: "Up About an hour",
|
|
||||||
expectedStatus: "Up an hour",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status without parentheses",
|
|
||||||
input: "Created",
|
|
||||||
expectedStatus: "Created",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty status",
|
|
||||||
input: "",
|
|
||||||
expectedStatus: "",
|
|
||||||
expectedHealth: container.DockerHealthNone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
status, health := parseDockerStatus(tt.input)
|
|
||||||
assert.Equal(t, tt.expectedStatus, status)
|
|
||||||
assert.Equal(t, tt.expectedHealth, health)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConstantsAndUtilityFunctions(t *testing.T) {
|
func TestConstantsAndUtilityFunctions(t *testing.T) {
|
||||||
// Test constants are properly defined
|
// Test constants are properly defined
|
||||||
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
assert.Equal(t, uint16(60000), defaultCacheTimeMs)
|
||||||
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
assert.Equal(t, uint64(5e9), maxNetworkSpeedBps)
|
||||||
assert.Equal(t, 2100, dockerTimeoutMs)
|
assert.Equal(t, 2100, dockerTimeoutMs)
|
||||||
assert.Equal(t, uint32(1024*1024), uint32(maxLogFrameSize)) // 1MB
|
|
||||||
assert.Equal(t, 5*1024*1024, maxTotalLogSize) // 5MB
|
|
||||||
|
|
||||||
// Test utility functions
|
// Test utility functions
|
||||||
assert.Equal(t, 1.5, twoDecimals(1.499))
|
assert.Equal(t, 1.5, twoDecimals(1.499))
|
||||||
@@ -943,290 +873,3 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
|
|||||||
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
|
assert.Equal(t, 0.5, bytesToMegabytes(524288)) // 512 KB
|
||||||
assert.Equal(t, 0.0, bytesToMegabytes(0))
|
assert.Equal(t, 0.0, bytesToMegabytes(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDecodeDockerLogStream(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input []byte
|
|
||||||
expected string
|
|
||||||
expectError bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "simple log entry",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 11 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0B,
|
|
||||||
'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd',
|
|
||||||
},
|
|
||||||
expected: "Hello World",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple frames",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'H', 'e', 'l', 'l', 'o',
|
|
||||||
// Frame 2: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'W', 'o', 'r', 'l', 'd',
|
|
||||||
},
|
|
||||||
expected: "HelloWorld",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero length frame",
|
|
||||||
input: []byte{
|
|
||||||
// Frame 1: stdout, 0 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
// Frame 2: stdout, 5 bytes
|
|
||||||
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05,
|
|
||||||
'H', 'e', 'l', 'l', 'o',
|
|
||||||
},
|
|
||||||
expected: "Hello",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: []byte{},
|
|
||||||
expected: "",
|
|
||||||
expectError: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
reader := bytes.NewReader(tt.input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
if tt.expectError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
} else {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, tt.expected, builder.String())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|
||||||
t.Run("excessively large frame should error", func(t *testing.T) {
|
|
||||||
// Create a frame with size exceeding maxLogFrameSize
|
|
||||||
excessiveSize := uint32(maxLogFrameSize + 1)
|
|
||||||
input := []byte{
|
|
||||||
// Frame header with excessive size
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(excessiveSize >> 24), byte(excessiveSize >> 16), byte(excessiveSize >> 8), byte(excessiveSize),
|
|
||||||
}
|
|
||||||
|
|
||||||
reader := bytes.NewReader(input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "log frame size")
|
|
||||||
assert.Contains(t, err.Error(), "exceeds maximum")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("total size limit should truncate", func(t *testing.T) {
|
|
||||||
// Create frames that exceed maxTotalLogSize (5MB)
|
|
||||||
// Use frames within maxLogFrameSize (1MB) to avoid single-frame rejection
|
|
||||||
frameSize := uint32(800 * 1024) // 800KB per frame
|
|
||||||
var input []byte
|
|
||||||
|
|
||||||
// Frames 1-6: 800KB each (total 4.8MB - within 5MB limit)
|
|
||||||
for i := 0; i < 6; i++ {
|
|
||||||
char := byte('A' + i)
|
|
||||||
frameHeader := []byte{
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
|
||||||
}
|
|
||||||
input = append(input, frameHeader...)
|
|
||||||
input = append(input, bytes.Repeat([]byte{char}, int(frameSize))...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frame 7: 800KB (would bring total to 5.6MB, exceeding 5MB limit - should be truncated)
|
|
||||||
frame7Header := []byte{
|
|
||||||
0x01, 0x00, 0x00, 0x00,
|
|
||||||
byte(frameSize >> 24), byte(frameSize >> 16), byte(frameSize >> 8), byte(frameSize),
|
|
||||||
}
|
|
||||||
input = append(input, frame7Header...)
|
|
||||||
input = append(input, bytes.Repeat([]byte{'Z'}, int(frameSize))...)
|
|
||||||
|
|
||||||
reader := bytes.NewReader(input)
|
|
||||||
var builder strings.Builder
|
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
|
||||||
|
|
||||||
// Should complete without error (graceful truncation)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
// Should have read 6 frames (4.8MB total, stopping before 7th would exceed 5MB limit)
|
|
||||||
expectedSize := int(frameSize) * 6
|
|
||||||
assert.Equal(t, expectedSize, builder.Len())
|
|
||||||
// Should contain A-F but not Z
|
|
||||||
result := builder.String()
|
|
||||||
assert.Contains(t, result, "A")
|
|
||||||
assert.Contains(t, result, "F")
|
|
||||||
assert.NotContains(t, result, "Z")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldExcludeContainer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
containerName string
|
|
||||||
patterns []string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty patterns excludes nothing",
|
|
||||||
containerName: "any-container",
|
|
||||||
patterns: []string{},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match - excluded",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"test-web", "test-api"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "exact match - not excluded",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-web", "test-api"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard prefix match - excluded",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard prefix match - not excluded",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-*"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard suffix match - excluded",
|
|
||||||
containerName: "myapp-staging",
|
|
||||||
patterns: []string{"*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard suffix match - not excluded",
|
|
||||||
containerName: "myapp-prod",
|
|
||||||
patterns: []string{"*-staging"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard both sides match - excluded",
|
|
||||||
containerName: "test-myapp-staging",
|
|
||||||
patterns: []string{"*-myapp-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard both sides match - not excluded",
|
|
||||||
containerName: "prod-yourapp-live",
|
|
||||||
patterns: []string{"*-myapp-*"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - matches first",
|
|
||||||
containerName: "test-container",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - matches second",
|
|
||||||
containerName: "myapp-staging",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns - no match",
|
|
||||||
containerName: "prod-web",
|
|
||||||
patterns: []string{"test-*", "*-staging"},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed exact and wildcard - exact match",
|
|
||||||
containerName: "temp-container",
|
|
||||||
patterns: []string{"temp-container", "test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed exact and wildcard - wildcard match",
|
|
||||||
containerName: "test-web",
|
|
||||||
patterns: []string{"temp-container", "test-*"},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
dm := &dockerManager{
|
|
||||||
excludeContainers: tt.patterns,
|
|
||||||
}
|
|
||||||
result := dm.shouldExcludeContainer(tt.containerName)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnsiEscapePattern(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no ANSI codes",
|
|
||||||
input: "Hello, World!",
|
|
||||||
expected: "Hello, World!",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "simple color code",
|
|
||||||
input: "\x1b[34mINFO\x1b[0m client mode",
|
|
||||||
expected: "INFO client mode",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple color codes",
|
|
||||||
input: "\x1b[31mERROR\x1b[0m: \x1b[33mWarning\x1b[0m message",
|
|
||||||
expected: "ERROR: Warning message",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bold and color",
|
|
||||||
input: "\x1b[1;32mSUCCESS\x1b[0m",
|
|
||||||
expected: "SUCCESS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cursor movement codes",
|
|
||||||
input: "Line 1\x1b[KLine 2",
|
|
||||||
expected: "Line 1Line 2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "256 color code",
|
|
||||||
input: "\x1b[38;5;196mRed text\x1b[0m",
|
|
||||||
expected: "Red text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RGB/truecolor code",
|
|
||||||
input: "\x1b[38;2;255;0;0mRed text\x1b[0m",
|
|
||||||
expected: "Red text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed content with newlines",
|
|
||||||
input: "\x1b[34m2024-01-01 12:00:00\x1b[0m INFO Starting\n\x1b[31m2024-01-01 12:00:01\x1b[0m ERROR Failed",
|
|
||||||
expected: "2024-01-01 12:00:00 INFO Starting\n2024-01-01 12:00:01 ERROR Failed",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := ansiEscapePattern.ReplaceAllString(tt.input, "")
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -49,12 +49,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() (err error) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
// Build command arguments, optionally selecting a device via -d
|
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
||||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
|
||||||
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
|
||||||
args = append(args, "-d", dev)
|
|
||||||
}
|
|
||||||
cmd := exec.Command(intelGpuStatsCmd, args...)
|
|
||||||
// Avoid blocking if intel_gpu_top writes to stderr
|
// Avoid blocking if intel_gpu_top writes to stderr
|
||||||
cmd.Stderr = io.Discard
|
cmd.Stderr = io.Discard
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
@@ -134,9 +129,7 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
// Collect engine names from header1
|
// Collect engine names from header1
|
||||||
for _, col := range h1 {
|
for _, col := range h1 {
|
||||||
key := strings.TrimRightFunc(col, func(r rune) bool {
|
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
|
||||||
return (r >= '0' && r <= '9') || r == '/'
|
|
||||||
})
|
|
||||||
var friendly string
|
var friendly string
|
||||||
switch key {
|
switch key {
|
||||||
case "RCS":
|
case "RCS":
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1439,15 +1437,6 @@ func TestParseIntelHeaders(t *testing.T) {
|
|||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "basic headers with RCS BCS VCS using index in name",
|
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
|
||||||
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
|
||||||
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
|
||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "headers with only RCS",
|
name: "headers with only RCS",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
@@ -1635,42 +1624,3 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
|
|
||||||
// Prepare a file to capture args
|
|
||||||
argsFile := filepath.Join(dir, "args.txt")
|
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
|
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
|
||||||
script := fmt.Sprintf(`#!/bin/sh
|
|
||||||
echo "$@" > %s
|
|
||||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
|
||||||
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
|
||||||
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
|
||||||
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
|
||||||
`, argsFile)
|
|
||||||
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set device selector via prefixed env var
|
|
||||||
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
|
||||||
|
|
||||||
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
|
||||||
if err := gm.collectIntelStats(); err != nil {
|
|
||||||
t.Fatalf("collectIntelStats error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that -d sriov was passed
|
|
||||||
data, err := os.ReadFile(argsFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed reading args file: %v", err)
|
|
||||||
}
|
|
||||||
argsStr := strings.TrimSpace(string(data))
|
|
||||||
require.Contains(t, argsStr, "-d sriov")
|
|
||||||
require.Contains(t, argsStr, "-s ")
|
|
||||||
require.Contains(t, argsStr, "-l")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlerContext provides context for request handlers
|
// HandlerContext provides context for request handlers
|
||||||
@@ -47,10 +43,6 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
|
|
||||||
registry.Register(common.GetData, &GetDataHandler{})
|
registry.Register(common.GetData, &GetDataHandler{})
|
||||||
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
registry.Register(common.CheckFingerprint, &CheckFingerprintHandler{})
|
||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -94,7 +86,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
var options common.DataRequestOptions
|
var options common.DataRequestOptions
|
||||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||||
|
|
||||||
sysStats := hctx.Agent.gatherStats(options)
|
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
||||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,99 +99,3 @@ type CheckFingerprintHandler struct{}
|
|||||||
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
func (h *CheckFingerprintHandler) Handle(hctx *HandlerContext) error {
|
||||||
return hctx.Client.handleAuthChallenge(hctx.Request, hctx.RequestID)
|
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(false); err != nil {
|
|
||||||
slog.Debug("smart refresh failed", "err", err)
|
|
||||||
}
|
|
||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// GetSystemdInfoHandler handles detailed systemd service info requests
|
|
||||||
type GetSystemdInfoHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.systemdManager == nil {
|
|
||||||
return errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.SystemdInfoRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ServiceName == "" {
|
|
||||||
return errors.New("service name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(details, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -172,24 +172,8 @@ func (a *Agent) sumAndTrackPerNicDeltas(cacheTimeMs uint16, msElapsed uint64, ne
|
|||||||
tracker.Set(upKey, v.BytesSent)
|
tracker.Set(upKey, v.BytesSent)
|
||||||
tracker.Set(downKey, v.BytesRecv)
|
tracker.Set(downKey, v.BytesRecv)
|
||||||
if msElapsed > 0 {
|
if msElapsed > 0 {
|
||||||
if prevVal, ok := tracker.Previous(upKey); ok {
|
upDelta = tracker.Delta(upKey) * 1000 / msElapsed
|
||||||
var deltaBytes uint64
|
downDelta = tracker.Delta(downKey) * 1000 / msElapsed
|
||||||
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}
|
systemStats.NetworkInterfaces[v.Name] = [4]uint64{upDelta, downDelta, v.BytesSent, v.BytesRecv}
|
||||||
}
|
}
|
||||||
@@ -228,10 +212,6 @@ func (a *Agent) applyNetworkTotals(
|
|||||||
a.initializeNetIoStats()
|
a.initializeNetIoStats()
|
||||||
delete(a.netIoStats, cacheTimeMs)
|
delete(a.netIoStats, cacheTimeMs)
|
||||||
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
delete(a.netInterfaceDeltaTrackers, cacheTimeMs)
|
||||||
systemStats.NetworkSent = 0
|
|
||||||
systemStats.NetworkRecv = 0
|
|
||||||
systemStats.Bandwidth[0], systemStats.Bandwidth[1] = 0, 0
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
systemStats.NetworkSent = networkSentPs
|
systemStats.NetworkSent = networkSentPs
|
||||||
|
|||||||
@@ -338,43 +338,6 @@ func TestSumAndTrackPerNicDeltas(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(7000), ni[1])
|
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) {
|
func TestApplyNetworkTotals(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -478,13 +441,10 @@ func TestApplyNetworkTotals(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if tt.expectReset {
|
if tt.expectReset {
|
||||||
// Should have reset network tracking state - maps cleared and stats zeroed
|
// Should have reset network tracking state - delta trackers should be cleared
|
||||||
assert.NotContains(t, a.netIoStats, cacheTimeMs, "cache entry should be cleared after reset")
|
// Note: initializeNetIoStats resets the maps, then applyNetworkTotals sets nis back
|
||||||
|
assert.Contains(t, a.netIoStats, cacheTimeMs, "cache entry should exist after reset")
|
||||||
assert.NotContains(t, a.netInterfaceDeltaTrackers, cacheTimeMs, "tracker should be cleared on 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 {
|
} else {
|
||||||
// Should have applied stats
|
// Should have applied stats
|
||||||
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
assert.Equal(t, tt.expectedNetworkSent, systemStats.NetworkSent)
|
||||||
|
|||||||
@@ -13,9 +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/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -170,12 +168,6 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
switch v := data.(type) {
|
switch v := data.(type) {
|
||||||
case *system.CombinedData:
|
case *system.CombinedData:
|
||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
default:
|
default:
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
||||||
}
|
}
|
||||||
@@ -202,7 +194,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
stats := a.gatherStats(60_000)
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||||
assert.Error(t, err, "Should not be valid JSON data")
|
assert.Error(t, err, "Should not be valid JSON data")
|
||||||
|
|
||||||
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
|
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
||||||
} else {
|
} else {
|
||||||
// Should be JSON - try to decode as JSON
|
// Should be JSON - try to decode as JSON
|
||||||
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should not be valid CBOR data")
|
assert.Error(t, err, "Should not be valid CBOR data")
|
||||||
|
|
||||||
// Verify the decoded JSON data matches our test data
|
// Verify the decoded JSON data matches our test data
|
||||||
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
|
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||||
|
|
||||||
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||||
@@ -551,8 +551,12 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
DiskPct: 50.0,
|
DiskPct: 50.0,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Cores: 8,
|
||||||
|
CpuModel: "Test CPU Model",
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
|
Os: system.Linux,
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
{
|
||||||
|
|||||||
1024
agent/smart.go
1024
agent/smart.go
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "errors"
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
return "", errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
@@ -1,815 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseSmartForScsi(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed reading fixture: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForScsi(data)
|
|
||||||
if !hasData {
|
|
||||||
t.Fatalf("expected SCSI data to parse successfully")
|
|
||||||
}
|
|
||||||
if exitStatus != 0 {
|
|
||||||
t.Fatalf("expected exit status 0, got %d", exitStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
|
|
||||||
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
|
|
||||||
assert.Equal(t, deviceData.FirmwareVersion, "C240")
|
|
||||||
assert.Equal(t, deviceData.DiskName, "/dev/sde")
|
|
||||||
assert.Equal(t, deviceData.DiskType, "scsi")
|
|
||||||
assert.EqualValues(t, deviceData.Temperature, 34)
|
|
||||||
assert.Equal(t, deviceData.SmartStatus, "PASSED")
|
|
||||||
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
|
|
||||||
|
|
||||||
if len(deviceData.Attributes) == 0 {
|
|
||||||
t.Fatalf("expected attributes to be populated")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSata(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 64, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
|
||||||
|
|
||||||
assert.Equal(t, "P3-2TB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/sda", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "sat", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(31), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
|
||||||
jsonPayload := []byte(`{
|
|
||||||
"smartctl": {"exit_status": 0},
|
|
||||||
"device": {"name": "/dev/sdz", "type": "sat"},
|
|
||||||
"model_name": "Example",
|
|
||||||
"serial_number": "PARENTHESES123",
|
|
||||||
"firmware_version": "1.0",
|
|
||||||
"user_capacity": {"bytes": 1024},
|
|
||||||
"smart_status": {"passed": true},
|
|
||||||
"temperature": {"current": 25},
|
|
||||||
"ata_smart_attributes": {
|
|
||||||
"table": [
|
|
||||||
{
|
|
||||||
"id": 9,
|
|
||||||
"name": "Power_On_Hours",
|
|
||||||
"value": 93,
|
|
||||||
"worst": 55,
|
|
||||||
"thresh": 0,
|
|
||||||
"when_failed": "",
|
|
||||||
"raw": {
|
|
||||||
"value": 57891864217128,
|
|
||||||
"string": "39925 (212 206 0)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForSata(jsonPayload)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
data, ok := sm.SmartDataMap["PARENTHESES123"]
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Len(t, data.Attributes, 1)
|
|
||||||
|
|
||||||
attr := data.Attributes[0]
|
|
||||||
assert.Equal(t, uint64(39925), attr.RawValue)
|
|
||||||
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartForNvme(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData, exitStatus := sm.parseSmartForNvme(data)
|
|
||||||
require.True(t, hasData)
|
|
||||||
assert.Equal(t, 0, exitStatus)
|
|
||||||
|
|
||||||
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
|
||||||
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
|
||||||
|
|
||||||
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
|
|
||||||
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
|
|
||||||
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
|
|
||||||
assert.Equal(t, "nvme", deviceData.DiskType)
|
|
||||||
assert.Equal(t, uint8(61), deviceData.Temperature)
|
|
||||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
||||||
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
|
|
||||||
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
|
|
||||||
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasDataForDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-1": {DiskName: "/dev/sda"},
|
|
||||||
"serial-2": nil,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.True(t, sm.hasDataForDevice("/dev/sda"))
|
|
||||||
assert.False(t, sm.hasDataForDevice("/dev/sdb"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
|
||||||
originalDevice := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: []*DeviceInfo{
|
|
||||||
originalDevice,
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot := sm.devicesSnapshot()
|
|
||||||
require.Len(t, snapshot, 2)
|
|
||||||
|
|
||||||
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
|
|
||||||
assert.Equal(t, "/dev/sda", snapshot[0].Name)
|
|
||||||
|
|
||||||
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
|
|
||||||
|
|
||||||
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
|
|
||||||
assert.Len(t, snapshot, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", ":sat")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
|
|
||||||
t.Setenv("SMART_DEVICES", " ")
|
|
||||||
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.ScanDevices(true)
|
|
||||||
assert.ErrorIs(t, err, errNoValidSmartData)
|
|
||||||
assert.Empty(t, sm.SmartDevices)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgsWithoutType(t *testing.T) {
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda"}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
args := sm.smartctlArgs(device, true)
|
|
||||||
assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartctlArgs(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "-n", "standby", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, true),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-d", "sat", "-a", "--json=c", "/dev/sda"},
|
|
||||||
sm.smartctlArgs(sataDevice, false),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t,
|
|
||||||
[]string{"-a", "--json=c", "-n", "standby"},
|
|
||||||
sm.smartctlArgs(nil, true),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveRefreshError(t *testing.T) {
|
|
||||||
scanErr := errors.New("scan failed")
|
|
||||||
collectErr := errors.New("collect failed")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
devices []*DeviceInfo
|
|
||||||
data map[string]*smart.SmartData
|
|
||||||
scanErr error
|
|
||||||
collectErr error
|
|
||||||
expectedErr error
|
|
||||||
expectNoErr bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no devices returns scan error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "has data ignores errors",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: map[string]*smart.SmartData{"serial": {}},
|
|
||||||
scanErr: scanErr,
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectNoErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "collect error preferred",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "scan error returned when no data",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
scanErr: scanErr,
|
|
||||||
expectedErr: scanErr,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no errors returns sentinel",
|
|
||||||
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
expectedErr: errNoValidSmartData,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no devices collect error",
|
|
||||||
devices: nil,
|
|
||||||
data: make(map[string]*smart.SmartData),
|
|
||||||
collectErr: collectErr,
|
|
||||||
expectedErr: collectErr,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDevices: tt.devices,
|
|
||||||
SmartDataMap: tt.data,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
|
|
||||||
if tt.expectNoErr {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tt.expectedErr == nil {
|
|
||||||
assert.NoError(t, err)
|
|
||||||
} else {
|
|
||||||
assert.Equal(t, tt.expectedErr, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseScan(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
SmartDataMap: map[string]*smart.SmartData{
|
|
||||||
"serial-active": {DiskName: "/dev/sda"},
|
|
||||||
"serial-stale": {DiskName: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanJSON := []byte(`{
|
|
||||||
"devices": [
|
|
||||||
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
|
|
||||||
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
|
|
||||||
devices, hasData := sm.parseScan(scanJSON)
|
|
||||||
assert.True(t, hasData)
|
|
||||||
|
|
||||||
sm.updateSmartDevices(devices)
|
|
||||||
|
|
||||||
require.Len(t, sm.SmartDevices, 2)
|
|
||||||
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
||||||
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
||||||
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
||||||
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
||||||
|
|
||||||
_, activeExists := sm.SmartDataMap["serial-active"]
|
|
||||||
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
|
|
||||||
|
|
||||||
_, staleExists := sm.SmartDataMap["serial-stale"]
|
|
||||||
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
||||||
{Name: "/dev/nvme0", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
configured := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat-override"},
|
|
||||||
{Name: "/dev/sdb", Type: "sat"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(nil, scanned, configured)
|
|
||||||
require.Len(t, merged, 3)
|
|
||||||
|
|
||||||
byName := make(map[string]*DeviceInfo, len(merged))
|
|
||||||
for _, dev := range merged {
|
|
||||||
byName[dev.Name] = dev
|
|
||||||
}
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sda")
|
|
||||||
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
|
|
||||||
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/nvme0")
|
|
||||||
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
|
|
||||||
|
|
||||||
require.Contains(t, byName, "/dev/sdb")
|
|
||||||
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
|
||||||
existing := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
scanned := []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda", Type: "nvme"},
|
|
||||||
}
|
|
||||||
|
|
||||||
merged := mergeDeviceLists(existing, scanned, nil)
|
|
||||||
require.Len(t, merged, 1)
|
|
||||||
|
|
||||||
device := merged[0]
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/nvme0"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "nvme", device.Type)
|
|
||||||
assert.Equal(t, "nvme", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
||||||
data, err := os.ReadFile(fixturePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"}
|
|
||||||
|
|
||||||
require.True(t, sm.parseSmartOutput(device, data))
|
|
||||||
assert.Equal(t, "sat+megaraid", device.Type)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
assert.True(t, device.typeVerified)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
|
||||||
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
||||||
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
|
|
||||||
|
|
||||||
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
|
|
||||||
assert.False(t, device.typeVerified)
|
|
||||||
assert.Equal(t, "sat", device.parserType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
|
|
||||||
t.Helper()
|
|
||||||
attr := findAttr(attributes, name)
|
|
||||||
if attr == nil {
|
|
||||||
t.Fatalf("expected attribute %s to be present", name)
|
|
||||||
}
|
|
||||||
if attr.RawValue != expected {
|
|
||||||
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
|
|
||||||
for _, attr := range attributes {
|
|
||||||
if attr != nil && attr.Name == name {
|
|
||||||
return attr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForSata{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDevice(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceNvme(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
|
|
||||||
{"qemu virtual", "QEMU NVMe Ctrl", true},
|
|
||||||
{"virtualbox virtual", "VBOX NVMe", true},
|
|
||||||
{"vmware virtual", "VMWARE NVMe", true},
|
|
||||||
{"virtual in model", "Virtual NVMe Device", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForNvme{
|
|
||||||
ModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceNvme(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsVirtualDeviceScsi(t *testing.T) {
|
|
||||||
sm := &SmartManager{}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
vendor string
|
|
||||||
product string
|
|
||||||
model string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
||||||
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
||||||
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
||||||
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
||||||
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
||||||
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
||||||
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
data := &smart.SmartInfoForScsi{
|
|
||||||
ScsiVendor: tt.vendor,
|
|
||||||
ScsiProduct: tt.product,
|
|
||||||
ScsiModelName: tt.model,
|
|
||||||
}
|
|
||||||
result := sm.isVirtualDeviceScsi(data)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
envValue string
|
|
||||||
expectedDevs map[string]struct{}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty env",
|
|
||||||
envValue: "",
|
|
||||||
expectedDevs: map[string]struct{}{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single device",
|
|
||||||
envValue: "/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "devices with whitespace",
|
|
||||||
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "duplicate devices",
|
|
||||||
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty entries and whitespace",
|
|
||||||
envValue: "/dev/sda,, /dev/sdb , , ",
|
|
||||||
expectedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if tt.envValue != "" {
|
|
||||||
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
|
||||||
} else {
|
|
||||||
// Ensure env var is not set for empty test
|
|
||||||
os.Unsetenv("EXCLUDE_SMART")
|
|
||||||
}
|
|
||||||
|
|
||||||
sm := &SmartManager{}
|
|
||||||
sm.refreshExcludedDevices()
|
|
||||||
|
|
||||||
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExcludedDevice(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
deviceName string
|
|
||||||
expectedBool bool
|
|
||||||
}{
|
|
||||||
{"excluded device sda", "/dev/sda", true},
|
|
||||||
{"excluded device nvme0", "/dev/nvme0", true},
|
|
||||||
{"non-excluded device sdb", "/dev/sdb", false},
|
|
||||||
{"non-excluded device nvme1", "/dev/nvme1", false},
|
|
||||||
{"empty device name", "", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := sm.isExcludedDevice(tt.deviceName)
|
|
||||||
assert.Equal(t, tt.expectedBool, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFilterExcludedDevices(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
excludedDevs map[string]struct{}
|
|
||||||
inputDevices []*DeviceInfo
|
|
||||||
expectedDevs []*DeviceInfo
|
|
||||||
expectedLength int
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no exclusions",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
},
|
|
||||||
expectedLength: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "some devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/nvme0": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme0"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
{Name: "/dev/nvme1"},
|
|
||||||
},
|
|
||||||
expectedLength: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all devices excluded",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
"/dev/sdb": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nil devices",
|
|
||||||
excludedDevs: map[string]struct{}{},
|
|
||||||
inputDevices: nil,
|
|
||||||
expectedDevs: []*DeviceInfo{},
|
|
||||||
expectedLength: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filter nil and empty name devices",
|
|
||||||
excludedDevs: map[string]struct{}{
|
|
||||||
"/dev/sda": {},
|
|
||||||
},
|
|
||||||
inputDevices: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sda"},
|
|
||||||
nil,
|
|
||||||
{Name: ""},
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedDevs: []*DeviceInfo{
|
|
||||||
{Name: "/dev/sdb"},
|
|
||||||
},
|
|
||||||
expectedLength: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sm := &SmartManager{
|
|
||||||
excludedDevices: tt.excludedDevs,
|
|
||||||
}
|
|
||||||
|
|
||||||
result := sm.filterExcludedDevices(tt.inputDevices)
|
|
||||||
|
|
||||||
assert.Len(t, result, tt.expectedLength)
|
|
||||||
assert.Equal(t, tt.expectedDevs, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsNvmeControllerPath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
path string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
// Controller paths (should return true)
|
|
||||||
{"/dev/nvme0", true},
|
|
||||||
{"/dev/nvme1", true},
|
|
||||||
{"/dev/nvme10", true},
|
|
||||||
{"nvme0", true},
|
|
||||||
|
|
||||||
// Namespace paths (should return false)
|
|
||||||
{"/dev/nvme0n1", false},
|
|
||||||
{"/dev/nvme1n1", false},
|
|
||||||
{"/dev/nvme0n1p1", false},
|
|
||||||
{"nvme0n1", false},
|
|
||||||
|
|
||||||
// Non-NVMe paths (should return false)
|
|
||||||
{"/dev/sda", false},
|
|
||||||
{"/dev/sda1", false},
|
|
||||||
{"/dev/hda", false},
|
|
||||||
{"", false},
|
|
||||||
{"/dev/nvme", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.path, func(t *testing.T) {
|
|
||||||
result := isNvmeControllerPath(tt.path)
|
|
||||||
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "embed"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed smartmontools/smartctl.exe
|
|
||||||
var embeddedSmartctl []byte
|
|
||||||
|
|
||||||
var (
|
|
||||||
smartctlOnce sync.Once
|
|
||||||
smartctlPath string
|
|
||||||
smartctlErr error
|
|
||||||
)
|
|
||||||
|
|
||||||
func ensureEmbeddedSmartctl() (string, error) {
|
|
||||||
smartctlOnce.Do(func() {
|
|
||||||
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
|
|
||||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
destPath := filepath.Join(destDir, "smartctl.exe")
|
|
||||||
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
|
|
||||||
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
smartctlPath = destPath
|
|
||||||
})
|
|
||||||
|
|
||||||
return smartctlPath, smartctlErr
|
|
||||||
}
|
|
||||||
139
agent/system.go
139
agent/system.go
@@ -2,18 +2,15 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
|
||||||
"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"
|
||||||
@@ -30,79 +27,41 @@ type prevDisk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) refreshStaticInfo() {
|
func (a *Agent) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
|
a.systemInfo.Hostname, _ = os.Hostname()
|
||||||
// get host info from Docker if available
|
|
||||||
var hostInfo container.HostInfo
|
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
|
||||||
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
|
||||||
hostInfo, _ = a.dockerManager.GetHostInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
a.systemDetails.Hostname, _ = os.Hostname()
|
|
||||||
if arch, err := host.KernelArch(); err == nil {
|
|
||||||
a.systemDetails.Arch = arch
|
|
||||||
} else {
|
|
||||||
a.systemDetails.Arch = runtime.GOARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
platform, _, version, _ := host.PlatformInformation()
|
platform, _, version, _ := host.PlatformInformation()
|
||||||
|
|
||||||
if platform == "darwin" {
|
if platform == "darwin" {
|
||||||
a.systemDetails.Os = system.Darwin
|
a.systemInfo.KernelVersion = version
|
||||||
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
|
a.systemInfo.Os = system.Darwin
|
||||||
} else if strings.Contains(platform, "indows") {
|
} else if strings.Contains(platform, "indows") {
|
||||||
a.systemDetails.Os = system.Windows
|
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
|
||||||
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
|
a.systemInfo.Os = system.Windows
|
||||||
a.systemDetails.Kernel = version
|
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemDetails.Os = system.Freebsd
|
a.systemInfo.Os = system.Freebsd
|
||||||
a.systemDetails.Kernel, _ = host.KernelVersion()
|
a.systemInfo.KernelVersion = version
|
||||||
if prettyName, err := getOsPrettyName(); err == nil {
|
|
||||||
a.systemDetails.OsName = prettyName
|
|
||||||
} else {
|
|
||||||
a.systemDetails.OsName = "FreeBSD"
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
a.systemDetails.Os = system.Linux
|
a.systemInfo.Os = system.Linux
|
||||||
a.systemDetails.OsName = hostInfo.OperatingSystem
|
}
|
||||||
if a.systemDetails.OsName == "" {
|
|
||||||
if prettyName, err := getOsPrettyName(); err == nil {
|
if a.systemInfo.KernelVersion == "" {
|
||||||
a.systemDetails.OsName = prettyName
|
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||||
} else {
|
|
||||||
a.systemDetails.OsName = platform
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.systemDetails.Kernel = hostInfo.KernelVersion
|
|
||||||
if a.systemDetails.Kernel == "" {
|
|
||||||
a.systemDetails.Kernel, _ = host.KernelVersion()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu model
|
// cpu model
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
a.systemDetails.CpuModel = info[0].ModelName
|
a.systemInfo.CpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
// cores / threads
|
// cores / threads
|
||||||
cores, _ := cpu.Counts(false)
|
a.systemInfo.Cores, _ = cpu.Counts(false)
|
||||||
threads := hostInfo.NCPU
|
if threads, err := cpu.Counts(true); err == nil {
|
||||||
if threads == 0 {
|
if threads > 0 && threads < a.systemInfo.Cores {
|
||||||
threads, _ = cpu.Counts(true)
|
// in lxc logical cores reflects container limits, so use that as cores if lower
|
||||||
}
|
a.systemInfo.Cores = threads
|
||||||
// in lxc, logical cores reflects container limits, so use that as cores if lower
|
} else {
|
||||||
if threads > 0 && threads < cores {
|
a.systemInfo.Threads = threads
|
||||||
cores = threads
|
|
||||||
}
|
|
||||||
a.systemDetails.Cores = cores
|
|
||||||
a.systemDetails.Threads = threads
|
|
||||||
|
|
||||||
// total memory
|
|
||||||
a.systemDetails.MemoryTotal = hostInfo.MemTotal
|
|
||||||
if a.systemDetails.MemoryTotal == 0 {
|
|
||||||
if v, err := mem.VirtualMemory(); err == nil {
|
|
||||||
a.systemDetails.MemoryTotal = v.Total
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,29 +78,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|
||||||
// battery
|
// battery
|
||||||
if batteryPercent, batteryState, err := battery.GetBatteryStats(); err == nil {
|
if battery.HasReadableBattery() {
|
||||||
systemStats.Battery[0] = batteryPercent
|
systemStats.Battery[0], systemStats.Battery[1], _ = battery.GetBatteryStats()
|
||||||
systemStats.Battery[1] = batteryState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu metrics
|
// cpu percent
|
||||||
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
cpuPercent, err := getCpuPercent(cacheTimeMs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
systemStats.Cpu = twoDecimals(cpuPercent)
|
||||||
systemStats.CpuBreakdown = []float64{
|
|
||||||
twoDecimals(cpuMetrics.User),
|
|
||||||
twoDecimals(cpuMetrics.System),
|
|
||||||
twoDecimals(cpuMetrics.Iowait),
|
|
||||||
twoDecimals(cpuMetrics.Steal),
|
|
||||||
twoDecimals(cpuMetrics.Idle),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting cpu metrics", "err", err)
|
slog.Error("Error getting cpu percent", "err", err)
|
||||||
}
|
|
||||||
|
|
||||||
// per-core cpu usage
|
|
||||||
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
|
||||||
systemStats.CpuCoresUsage = perCoreUsage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// load average
|
// load average
|
||||||
@@ -236,16 +182,20 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// update system info
|
// update base system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
|
// TODO: remove these in future release in favor of load avg array
|
||||||
|
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
||||||
|
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
||||||
|
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
a.systemInfo.Battery = systemStats.Battery
|
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
|
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
||||||
|
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
a.systemInfo.Threads = a.systemDetails.Threads
|
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
slog.Debug("sysinfo", "data", a.systemInfo)
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
@@ -276,24 +226,3 @@ func getARCSize() (uint64, error) {
|
|||||||
|
|
||||||
return 0, fmt.Errorf("failed to parse size field")
|
return 0, fmt.Errorf("failed to parse size field")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
|
||||||
func getOsPrettyName() (string, error) {
|
|
||||||
file, err := os.Open("/etc/os-release")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
|
||||||
value := after
|
|
||||||
value = strings.Trim(value, `"`)
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", errors.New("pretty name not found")
|
|
||||||
}
|
|
||||||
|
|||||||
273
agent/systemd.go
273
agent/systemd.go
@@ -1,273 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"maps"
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/coreos/go-systemd/v22/dbus"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
var errNoActiveTime = errors.New("no active time")
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
sync.Mutex
|
|
||||||
serviceStatsMap map[string]*systemd.Service
|
|
||||||
isRunning bool
|
|
||||||
hasFreshStats bool
|
|
||||||
patterns []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &systemdManager{
|
|
||||||
serviceStatsMap: make(map[string]*systemd.Service),
|
|
||||||
patterns: getServicePatterns(),
|
|
||||||
}
|
|
||||||
|
|
||||||
manager.startWorker(conn)
|
|
||||||
|
|
||||||
return manager, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) startWorker(conn *dbus.Conn) {
|
|
||||||
if sm.isRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sm.isRunning = true
|
|
||||||
// prime the service stats map with the current services
|
|
||||||
_ = sm.getServiceStats(conn, true)
|
|
||||||
// update the services every 10 minutes
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
time.Sleep(time.Minute * 10)
|
|
||||||
_ = sm.getServiceStats(nil, true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns the number of systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return len(sm.serviceStatsMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns the number of systemd services in a failed state.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
count := uint16(0)
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
if service.State == systemd.StatusFailed {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats collects statistics for all running systemd services.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
|
||||||
// start := time.Now()
|
|
||||||
// defer func() {
|
|
||||||
// slog.Info("systemdManager.getServiceStats", "duration", time.Since(start))
|
|
||||||
// }()
|
|
||||||
|
|
||||||
var services []*systemd.Service
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if !refresh {
|
|
||||||
// return nil
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
for _, service := range sm.serviceStatsMap {
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = false
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
if conn == nil || !conn.Connected() {
|
|
||||||
conn, err = dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("Error listing systemd service units", "err", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, unit := range units {
|
|
||||||
service, err := sm.updateServiceStats(conn, unit)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
services = append(services, service)
|
|
||||||
}
|
|
||||||
sm.hasFreshStats = true
|
|
||||||
return services
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateServiceStats updates the statistics for a single systemd service.
|
|
||||||
func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStatus) (*systemd.Service, error) {
|
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// if service has never been active (no active since time), skip it
|
|
||||||
if activeEnterTsProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Unit", "ActiveEnterTimestamp"); err == nil {
|
|
||||||
if ts, ok := activeEnterTsProp.Value.Value().(uint64); !ok || ts == 0 || ts == math.MaxUint64 {
|
|
||||||
return nil, errNoActiveTime
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
service, serviceExists := sm.serviceStatsMap[unit.Name]
|
|
||||||
if !serviceExists {
|
|
||||||
service = &systemd.Service{Name: unescapeServiceName(strings.TrimSuffix(unit.Name, ".service"))}
|
|
||||||
sm.serviceStatsMap[unit.Name] = service
|
|
||||||
}
|
|
||||||
|
|
||||||
memPeak := service.MemPeak
|
|
||||||
if memPeakProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryPeak"); err == nil {
|
|
||||||
// If memPeak is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memPeakProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memPeak = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var memUsage uint64
|
|
||||||
if memProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "MemoryCurrent"); err == nil {
|
|
||||||
// If memUsage is MaxUint64 the api is saying it's not available
|
|
||||||
if v, ok := memProp.Value.Value().(uint64); ok && v != math.MaxUint64 {
|
|
||||||
memUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.State = systemd.ParseServiceStatus(unit.ActiveState)
|
|
||||||
service.Sub = systemd.ParseServiceSubState(unit.SubState)
|
|
||||||
|
|
||||||
// some systems always return 0 for mem peak, so we should update the peak if the current usage is greater
|
|
||||||
if memUsage > memPeak {
|
|
||||||
memPeak = memUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
var cpuUsage uint64
|
|
||||||
if cpuProp, err := conn.GetUnitTypePropertyContext(ctx, unit.Name, "Service", "CPUUsageNSec"); err == nil {
|
|
||||||
if v, ok := cpuProp.Value.Value().(uint64); ok {
|
|
||||||
cpuUsage = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
service.Mem = memUsage
|
|
||||||
if memPeak > service.MemPeak {
|
|
||||||
service.MemPeak = memPeak
|
|
||||||
}
|
|
||||||
service.UpdateCPUPercent(cpuUsage)
|
|
||||||
|
|
||||||
return service, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceDetails collects extended information for a specific systemd service.
|
|
||||||
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
unitName := serviceName
|
|
||||||
if !strings.HasSuffix(unitName, ".service") {
|
|
||||||
unitName += ".service"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with all unit properties
|
|
||||||
details := make(systemd.ServiceDetails)
|
|
||||||
maps.Copy(details, props)
|
|
||||||
|
|
||||||
// // Add service-specific properties
|
|
||||||
servicePropNames := []string{
|
|
||||||
"MainPID", "ExecMainPID", "TasksCurrent", "TasksMax",
|
|
||||||
"MemoryCurrent", "MemoryPeak", "MemoryLimit", "CPUUsageNSec",
|
|
||||||
"NRestarts", "ExecMainStartTimestampRealtime", "Result",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, propName := range servicePropNames {
|
|
||||||
if variant, err := conn.GetUnitTypePropertyContext(ctx, unitName, "Service", propName); err == nil {
|
|
||||||
value := variant.Value.Value()
|
|
||||||
// Check if the value is MaxUint64, which indicates unlimited/infinite
|
|
||||||
if uint64Value, ok := value.(uint64); ok && uint64Value == math.MaxUint64 {
|
|
||||||
// Set to nil to indicate unlimited - frontend will handle this appropriately
|
|
||||||
details[propName] = nil
|
|
||||||
} else {
|
|
||||||
details[propName] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return details, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
|
|
||||||
func unescapeServiceName(name string) string {
|
|
||||||
if !strings.Contains(name, "\\x") {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
unescaped, err := strconv.Unquote("\"" + name + "\"")
|
|
||||||
if err != nil {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return unescaped
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServicePatterns returns the list of service patterns to match.
|
|
||||||
// It reads from the SERVICE_PATTERNS environment variable if set,
|
|
||||||
// otherwise defaults to "*service".
|
|
||||||
func getServicePatterns() []string {
|
|
||||||
patterns := []string{}
|
|
||||||
if envPatterns, _ := GetEnv("SERVICE_PATTERNS"); envPatterns != "" {
|
|
||||||
for pattern := range strings.SplitSeq(envPatterns, ",") {
|
|
||||||
pattern = strings.TrimSpace(pattern)
|
|
||||||
if pattern == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(pattern, ".service") {
|
|
||||||
pattern += ".service"
|
|
||||||
}
|
|
||||||
patterns = append(patterns, pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(patterns) == 0 {
|
|
||||||
patterns = []string{"*.service"}
|
|
||||||
}
|
|
||||||
return patterns
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
|
||||||
type systemdManager struct {
|
|
||||||
hasFreshStats bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
|
||||||
return &systemdManager{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStats returns nil for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServiceStatsCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getServiceStatsCount() int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFailedServiceCount returns 0 for non-linux systems.
|
|
||||||
func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
|
||||||
return nil, errors.New("systemd manager unavailable")
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
//go:build !linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewSystemdManager(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceStats(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test with refresh = true
|
|
||||||
result := manager.getServiceStats(true)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with refresh = false
|
|
||||||
result = manager.getServiceStats(false)
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerGetServiceDetails(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := manager.getServiceDetails("any-service")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
|
|
||||||
// Test with empty service name
|
|
||||||
result, err = manager.getServiceDetails("")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "systemd manager unavailable", err.Error())
|
|
||||||
assert.Nil(t, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdManagerFields(t *testing.T) {
|
|
||||||
manager, err := newSystemdManager()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// The non-linux manager should be a simple struct with no special fields
|
|
||||||
// We can't test private fields directly, but we can test the methods work
|
|
||||||
assert.NotNil(t, manager)
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
//go:build linux && testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUnescapeServiceName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"nginx.service", "nginx.service"}, // No escaping needed
|
|
||||||
{"test\\x2dwith\\x2ddashes.service", "test-with-dashes.service"}, // \x2d is dash
|
|
||||||
{"service\\x20with\\x20spaces.service", "service with spaces.service"}, // \x20 is space
|
|
||||||
{"mixed\\x2dand\\x2dnormal", "mixed-and-normal"}, // Mixed escaped and normal
|
|
||||||
{"no-escape-here", "no-escape-here"}, // No escape sequences
|
|
||||||
{"", ""}, // Empty string
|
|
||||||
{"\\x2d\\x2d", "--"}, // Multiple escapes
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
|
||||||
// Test invalid escape sequences - should return original string
|
|
||||||
invalidInputs := []string{
|
|
||||||
"invalid\\x", // Incomplete escape
|
|
||||||
"invalid\\xZZ", // Invalid hex
|
|
||||||
"invalid\\x2", // Incomplete hex
|
|
||||||
"invalid\\xyz", // Not a valid escape
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, input := range invalidInputs {
|
|
||||||
t.Run(input, func(t *testing.T) {
|
|
||||||
result := unescapeServiceName(input)
|
|
||||||
assert.Equal(t, input, result, "Invalid escape sequences should return original string")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetServicePatterns(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
prefixedEnv string
|
|
||||||
unprefixedEnv string
|
|
||||||
expected []string
|
|
||||||
cleanupEnvVars bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "default when no env var set",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with prefixed env",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single pattern with unprefixed env",
|
|
||||||
prefixedEnv: "",
|
|
||||||
unprefixedEnv: "nginx",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "prefixed env takes precedence",
|
|
||||||
prefixedEnv: "nginx",
|
|
||||||
unprefixedEnv: "apache",
|
|
||||||
expected: []string{"nginx.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple patterns",
|
|
||||||
prefixedEnv: "nginx,apache,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with .service suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed patterns with and without suffix",
|
|
||||||
prefixedEnv: "nginx.service,apache,postgresql.service",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "patterns with whitespace",
|
|
||||||
prefixedEnv: " nginx , apache , postgresql ",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty patterns are skipped",
|
|
||||||
prefixedEnv: "nginx,,apache, ,postgresql",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"nginx.service", "apache.service", "postgresql.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard pattern",
|
|
||||||
prefixedEnv: "*nginx*,*apache*",
|
|
||||||
unprefixedEnv: "",
|
|
||||||
expected: []string{"*nginx*.service", "*apache*.service"},
|
|
||||||
cleanupEnvVars: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Clean up any existing env vars
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
|
|
||||||
// Set up environment variables
|
|
||||||
if tt.prefixedEnv != "" {
|
|
||||||
os.Setenv("BESZEL_AGENT_SERVICE_PATTERNS", tt.prefixedEnv)
|
|
||||||
}
|
|
||||||
if tt.unprefixedEnv != "" {
|
|
||||||
os.Setenv("SERVICE_PATTERNS", tt.unprefixedEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the function
|
|
||||||
result := getServicePatterns()
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
assert.Equal(t, tt.expected, result, "Patterns should match expected values")
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
if tt.cleanupEnvVars {
|
|
||||||
os.Unsetenv("BESZEL_AGENT_SERVICE_PATTERNS")
|
|
||||||
os.Unsetenv("SERVICE_PATTERNS")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/nvme0"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761507494,
|
|
||||||
"asctime": "Sun Oct 26 15:38:14 2025 EDT"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
},
|
|
||||||
"model_name": "PELADN 512GB",
|
|
||||||
"serial_number": "2024031600129",
|
|
||||||
"firmware_version": "VC2S038E",
|
|
||||||
"nvme_pci_vendor": {
|
|
||||||
"id": 4332,
|
|
||||||
"subsystem_id": 4332
|
|
||||||
},
|
|
||||||
"nvme_ieee_oui_identifier": 57420,
|
|
||||||
"nvme_controller_id": 1,
|
|
||||||
"nvme_version": {
|
|
||||||
"string": "1.4",
|
|
||||||
"value": 66560
|
|
||||||
},
|
|
||||||
"nvme_number_of_namespaces": 1,
|
|
||||||
"nvme_namespaces": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"size": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"utilization": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"formatted_lba_size": 512,
|
|
||||||
"eui64": {
|
|
||||||
"oui": 57420,
|
|
||||||
"ext_id": 112094110470
|
|
||||||
},
|
|
||||||
"features": {
|
|
||||||
"value": 0,
|
|
||||||
"thin_provisioning": false,
|
|
||||||
"na_fields": false,
|
|
||||||
"dealloc_or_unwritten_block_error": false,
|
|
||||||
"uid_reuse": false,
|
|
||||||
"np_fields": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"lba_formats": [
|
|
||||||
{
|
|
||||||
"formatted": true,
|
|
||||||
"data_bytes": 512,
|
|
||||||
"metadata_bytes": 0,
|
|
||||||
"relative_performance": 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 1000215216,
|
|
||||||
"bytes": 512110190592
|
|
||||||
},
|
|
||||||
"logical_block_size": 512,
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"nvme_firmware_update_capabilities": {
|
|
||||||
"value": 2,
|
|
||||||
"slots": 1,
|
|
||||||
"first_slot_is_read_only": false,
|
|
||||||
"activiation_without_reset": false,
|
|
||||||
"multiple_update_detection": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_admin_commands": {
|
|
||||||
"value": 23,
|
|
||||||
"security_send_receive": true,
|
|
||||||
"format_nvm": true,
|
|
||||||
"firmware_download": true,
|
|
||||||
"namespace_management": false,
|
|
||||||
"self_test": true,
|
|
||||||
"directives": false,
|
|
||||||
"mi_send_receive": false,
|
|
||||||
"virtualization_management": false,
|
|
||||||
"doorbell_buffer_config": false,
|
|
||||||
"get_lba_status": false,
|
|
||||||
"command_and_feature_lockdown": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_optional_nvm_commands": {
|
|
||||||
"value": 94,
|
|
||||||
"compare": false,
|
|
||||||
"write_uncorrectable": true,
|
|
||||||
"dataset_management": true,
|
|
||||||
"write_zeroes": true,
|
|
||||||
"save_select_feature_nonzero": true,
|
|
||||||
"reservations": false,
|
|
||||||
"timestamp": true,
|
|
||||||
"verify": false,
|
|
||||||
"copy": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_log_page_attributes": {
|
|
||||||
"value": 2,
|
|
||||||
"smart_health_per_namespace": false,
|
|
||||||
"commands_effects_log": true,
|
|
||||||
"extended_get_log_page_cmd": false,
|
|
||||||
"telemetry_log": false,
|
|
||||||
"persistent_event_log": false,
|
|
||||||
"supported_log_pages_log": false,
|
|
||||||
"telemetry_data_area_4": false,
|
|
||||||
"other": 0
|
|
||||||
},
|
|
||||||
"nvme_maximum_data_transfer_pages": 32,
|
|
||||||
"nvme_composite_temperature_threshold": {
|
|
||||||
"warning": 100,
|
|
||||||
"critical": 110
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"op_limit_max": 100,
|
|
||||||
"critical_limit_max": 110,
|
|
||||||
"current": 61
|
|
||||||
},
|
|
||||||
"nvme_power_states": [
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 0,
|
|
||||||
"relative_read_throughput": 0,
|
|
||||||
"relative_write_latency": 0,
|
|
||||||
"relative_write_throughput": 0,
|
|
||||||
"entry_latency_us": 230000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 800,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 1,
|
|
||||||
"relative_read_throughput": 1,
|
|
||||||
"relative_write_latency": 1,
|
|
||||||
"relative_write_throughput": 1,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 50000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 400,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": false,
|
|
||||||
"relative_read_latency": 2,
|
|
||||||
"relative_read_throughput": 2,
|
|
||||||
"relative_write_latency": 2,
|
|
||||||
"relative_write_throughput": 2,
|
|
||||||
"entry_latency_us": 4000,
|
|
||||||
"exit_latency_us": 250000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 2,
|
|
||||||
"units_per_watt": 100
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 3,
|
|
||||||
"relative_read_throughput": 3,
|
|
||||||
"relative_write_latency": 3,
|
|
||||||
"relative_write_throughput": 3,
|
|
||||||
"entry_latency_us": 5000,
|
|
||||||
"exit_latency_us": 10000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 300,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"non_operational_state": true,
|
|
||||||
"relative_read_latency": 4,
|
|
||||||
"relative_read_throughput": 4,
|
|
||||||
"relative_write_latency": 4,
|
|
||||||
"relative_write_throughput": 4,
|
|
||||||
"entry_latency_us": 54000,
|
|
||||||
"exit_latency_us": 45000,
|
|
||||||
"max_power": {
|
|
||||||
"value": 50,
|
|
||||||
"scale": 1,
|
|
||||||
"units_per_watt": 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true,
|
|
||||||
"nvme": {
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nvme_smart_health_information_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"critical_warning": 0,
|
|
||||||
"temperature": 61,
|
|
||||||
"available_spare": 100,
|
|
||||||
"available_spare_threshold": 32,
|
|
||||||
"percentage_used": 0,
|
|
||||||
"data_units_read": 6573104,
|
|
||||||
"data_units_written": 16040567,
|
|
||||||
"host_reads": 63241130,
|
|
||||||
"host_writes": 253050006,
|
|
||||||
"controller_busy_time": 0,
|
|
||||||
"power_cycles": 430,
|
|
||||||
"power_on_hours": 4399,
|
|
||||||
"unsafe_shutdowns": 44,
|
|
||||||
"media_errors": 0,
|
|
||||||
"num_err_log_entries": 0,
|
|
||||||
"warning_temp_time": 0,
|
|
||||||
"critical_comp_time": 0
|
|
||||||
},
|
|
||||||
"spare_available": {
|
|
||||||
"current_percent": 100,
|
|
||||||
"threshold_percent": 32
|
|
||||||
},
|
|
||||||
"endurance_used": {
|
|
||||||
"current_percent": 0
|
|
||||||
},
|
|
||||||
"power_cycle_count": 430,
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 4399
|
|
||||||
},
|
|
||||||
"nvme_error_information_log": {
|
|
||||||
"size": 8,
|
|
||||||
"read": 8,
|
|
||||||
"unread": 0
|
|
||||||
},
|
|
||||||
"nvme_self_test_log": {
|
|
||||||
"nsid": -1,
|
|
||||||
"current_self_test_operation": {
|
|
||||||
"value": 0,
|
|
||||||
"string": "No self-test in progress"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"pre_release": false,
|
|
||||||
"svn_revision": "5714",
|
|
||||||
"platform_info": "x86_64-linux-6.17.1-2-cachyos",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"--scan",
|
|
||||||
"-j"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"devices": [
|
|
||||||
{
|
|
||||||
"name": "/dev/sda",
|
|
||||||
"info_name": "/dev/sda [SAT]",
|
|
||||||
"type": "sat",
|
|
||||||
"protocol": "ATA"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "/dev/nvme0",
|
|
||||||
"info_name": "/dev/nvme0",
|
|
||||||
"type": "nvme",
|
|
||||||
"protocol": "NVMe"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
{
|
|
||||||
"json_format_version": [
|
|
||||||
1,
|
|
||||||
0
|
|
||||||
],
|
|
||||||
"smartctl": {
|
|
||||||
"version": [
|
|
||||||
7,
|
|
||||||
3
|
|
||||||
],
|
|
||||||
"svn_revision": "5338",
|
|
||||||
"platform_info": "x86_64-linux-6.12.43+deb12-amd64",
|
|
||||||
"build_info": "(local build)",
|
|
||||||
"argv": [
|
|
||||||
"smartctl",
|
|
||||||
"-aj",
|
|
||||||
"/dev/sde"
|
|
||||||
],
|
|
||||||
"exit_status": 0
|
|
||||||
},
|
|
||||||
"local_time": {
|
|
||||||
"time_t": 1761502142,
|
|
||||||
"asctime": "Sun Oct 21 21:09:02 2025 MSK"
|
|
||||||
},
|
|
||||||
"device": {
|
|
||||||
"name": "/dev/sde",
|
|
||||||
"info_name": "/dev/sde",
|
|
||||||
"type": "scsi",
|
|
||||||
"protocol": "SCSI"
|
|
||||||
},
|
|
||||||
"scsi_vendor": "YADRO",
|
|
||||||
"scsi_product": "WUH721414AL4204",
|
|
||||||
"scsi_model_name": "YADRO WUH721414AL4204",
|
|
||||||
"scsi_revision": "C240",
|
|
||||||
"scsi_version": "SPC-4",
|
|
||||||
"user_capacity": {
|
|
||||||
"blocks": 3418095616,
|
|
||||||
"bytes": 14000519643136
|
|
||||||
},
|
|
||||||
"logical_block_size": 4096,
|
|
||||||
"scsi_lb_provisioning": {
|
|
||||||
"name": "fully provisioned",
|
|
||||||
"value": 0,
|
|
||||||
"management_enabled": {
|
|
||||||
"name": "LBPME",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
"read_zeros": {
|
|
||||||
"name": "LBPRZ",
|
|
||||||
"value": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rotation_rate": 7200,
|
|
||||||
"form_factor": {
|
|
||||||
"scsi_value": 2,
|
|
||||||
"name": "3.5 inches"
|
|
||||||
},
|
|
||||||
"logical_unit_id": "0x5000cca29063dc00",
|
|
||||||
"serial_number": "9YHSDH9B",
|
|
||||||
"device_type": {
|
|
||||||
"scsi_terminology": "Peripheral Device Type [PDT]",
|
|
||||||
"scsi_value": 0,
|
|
||||||
"name": "disk"
|
|
||||||
},
|
|
||||||
"scsi_transport_protocol": {
|
|
||||||
"name": "SAS (SPL-4)",
|
|
||||||
"value": 6
|
|
||||||
},
|
|
||||||
"smart_support": {
|
|
||||||
"available": true,
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"temperature_warning": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"smart_status": {
|
|
||||||
"passed": true
|
|
||||||
},
|
|
||||||
"temperature": {
|
|
||||||
"current": 34,
|
|
||||||
"drive_trip": 85
|
|
||||||
},
|
|
||||||
"power_on_time": {
|
|
||||||
"hours": 458,
|
|
||||||
"minutes": 25
|
|
||||||
},
|
|
||||||
"scsi_start_stop_cycle_counter": {
|
|
||||||
"year_of_manufacture": "2022",
|
|
||||||
"week_of_manufacture": "41",
|
|
||||||
"specified_cycle_count_over_device_lifetime": 50000,
|
|
||||||
"accumulated_start_stop_cycles": 2,
|
|
||||||
"specified_load_unload_count_over_device_lifetime": 600000,
|
|
||||||
"accumulated_load_unload_cycles": 418
|
|
||||||
},
|
|
||||||
"scsi_grown_defect_list": 0,
|
|
||||||
"scsi_error_counter_log": {
|
|
||||||
"read": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 346,
|
|
||||||
"gigabytes_processed": "3,641",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 4052,
|
|
||||||
"gigabytes_processed": "2124,590",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
},
|
|
||||||
"verify": {
|
|
||||||
"errors_corrected_by_eccfast": 0,
|
|
||||||
"errors_corrected_by_eccdelayed": 0,
|
|
||||||
"errors_corrected_by_rereads_rewrites": 0,
|
|
||||||
"total_errors_corrected": 0,
|
|
||||||
"correction_algorithm_invocations": 223,
|
|
||||||
"gigabytes_processed": "0,000",
|
|
||||||
"total_uncorrected_errors": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
|
|
||||||
"Containers": 14,
|
|
||||||
"ContainersRunning": 3,
|
|
||||||
"ContainersPaused": 1,
|
|
||||||
"ContainersStopped": 10,
|
|
||||||
"Images": 508,
|
|
||||||
"Driver": "overlay2",
|
|
||||||
"KernelVersion": "6.8.0-31-generic",
|
|
||||||
"OperatingSystem": "Ubuntu 24.04 LTS",
|
|
||||||
"OSVersion": "24.04",
|
|
||||||
"OSType": "linux",
|
|
||||||
"Architecture": "x86_64",
|
|
||||||
"NCPU": 4,
|
|
||||||
"MemTotal": 2095882240,
|
|
||||||
"ServerVersion": "27.0.1"
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Download smartctl.exe from the given URL and save it to the given destination.
|
|
||||||
// This is used to embed smartctl.exe in the Windows build.
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
|
|
||||||
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
|
|
||||||
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
|
|
||||||
force := flag.Bool("force", false, "Force re-download even if destination exists")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if *url == "" || *out == "" {
|
|
||||||
fatalf("-url and -out are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*force {
|
|
||||||
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
|
|
||||||
fmt.Println("smartctl.exe already present, skipping download")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := downloadFile(*url, *out, *sha); err != nil {
|
|
||||||
fatalf("download failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadFile(url, dest, shaHex string) error {
|
|
||||||
// Prepare destination
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("create dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTP client
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("new request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("http get: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
tmp := dest + ".tmp"
|
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
|
|
||||||
var hasher hash.Hash
|
|
||||||
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
|
|
||||||
switch len(cleanSha) {
|
|
||||||
case 40:
|
|
||||||
hasher = sha1.New()
|
|
||||||
case 64:
|
|
||||||
hasher = sha256.New()
|
|
||||||
default:
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var mw io.Writer = f
|
|
||||||
if hasher != nil {
|
|
||||||
mw = io.MultiWriter(f, hasher)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(mw, resp.Body); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("write tmp: %w", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("close tmp: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasher != nil && shaHex != "" {
|
|
||||||
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
|
|
||||||
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
|
|
||||||
if got != cleanSha {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make executable and move into place
|
|
||||||
if err := os.Chmod(tmp, 0o755); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("chmod: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmp, dest); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return fmt.Errorf("rename: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("smartctl.exe downloaded to", dest)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fatalf(format string, a ...any) {
|
|
||||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.0-beta.1"
|
Version = "0.13.2"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
49
go.mod
49
go.mod
@@ -1,25 +1,27 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.5
|
go 1.25.1
|
||||||
|
|
||||||
|
// 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
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0
|
|
||||||
github.com/distatus/battery v0.11.0
|
github.com/distatus/battery v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
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.12.1
|
github.com/nicholas-fedor/shoutrrr v0.10.0
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.34.0
|
github.com/pocketbase/pocketbase v0.30.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10
|
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.45.0
|
golang.org/x/crypto v0.42.0
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,38 +33,37 @@ require (
|
|||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.1 // indirect
|
github.com/ebitengine/purego v0.9.0 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.0 // indirect
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // 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 v1.0.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // 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
|
||||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.11.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.33.0 // indirect
|
golang.org/x/image v0.31.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.44.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.31.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.3 // 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.40.1 // indirect
|
modernc.org/sqlite v1.39.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
126
go.sum
126
go.sum
@@ -9,8 +9,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
@@ -25,16 +23,16 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
@@ -51,44 +49,40 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8=
|
|
||||||
github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
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-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||||
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
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.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
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-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/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 v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
github.com/nicholas-fedor/shoutrrr v0.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
|
github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||||
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=
|
||||||
@@ -96,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.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
|
github.com/pocketbase/pocketbase v0.30.1 h1:8lgfhH+HiSw1PyKVMq2sjtC4ZNvda2f/envTAzWMLOA=
|
||||||
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
|
github.com/pocketbase/pocketbase v0.30.1/go.mod h1:sUI+uekXZam5Wa0eh+DClc+HieKMCeqsHA7Ydd9vwyE=
|
||||||
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=
|
||||||
@@ -105,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.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
github.com/shirou/gopsutil/v4 v4.25.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
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=
|
||||||
@@ -118,77 +112,77 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||||
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.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.18.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=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||||
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/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/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
|
||||||
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.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
|
||||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
|
||||||
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=
|
||||||
@@ -197,8 +191,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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
||||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
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=
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ type AlertManager struct {
|
|||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
UserID string
|
UserID string
|
||||||
SystemID string
|
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
Link string
|
Link string
|
||||||
@@ -41,19 +40,13 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
|
||||||
Battery [2]uint8 `json:"bat"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
|
||||||
Usage float64 `json:"u"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -79,6 +72,7 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
|
"matrix": {},
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -105,84 +99,10 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
func (am *AlertManager) bindEvents() {
|
func (am *AlertManager) bindEvents() {
|
||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
|
||||||
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
|
||||||
// Query for quiet hours windows that match this user and system
|
|
||||||
// Include both global windows (system is null/empty) and system-specific windows
|
|
||||||
var filter string
|
|
||||||
var params dbx.Params
|
|
||||||
|
|
||||||
if systemID == "" {
|
|
||||||
// If no systemID provided, only check global windows
|
|
||||||
filter = "user={:user} AND system=''"
|
|
||||||
params = dbx.Params{"user": userID}
|
|
||||||
} else {
|
|
||||||
// Check both global and system-specific windows
|
|
||||||
filter = "user={:user} AND (system='' OR system={:system})"
|
|
||||||
params = dbx.Params{
|
|
||||||
"user": userID,
|
|
||||||
"system": systemID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
|
|
||||||
if err != nil || len(quietHourWindows) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
for _, window := range quietHourWindows {
|
|
||||||
windowType := window.GetString("type")
|
|
||||||
start := window.GetDateTime("start").Time()
|
|
||||||
end := window.GetDateTime("end").Time()
|
|
||||||
|
|
||||||
if windowType == "daily" {
|
|
||||||
// For daily recurring windows, extract just the time portion and compare
|
|
||||||
// The start/end are stored as full datetime but we only care about HH:MM
|
|
||||||
startHour, startMin, _ := start.Clock()
|
|
||||||
endHour, endMin, _ := end.Clock()
|
|
||||||
nowHour, nowMin, _ := now.Clock()
|
|
||||||
|
|
||||||
// Convert to minutes since midnight for easier comparison
|
|
||||||
startMinutes := startHour*60 + startMin
|
|
||||||
endMinutes := endHour*60 + endMin
|
|
||||||
nowMinutes := nowHour*60 + nowMin
|
|
||||||
|
|
||||||
// Handle case where window crosses midnight
|
|
||||||
if endMinutes < startMinutes {
|
|
||||||
// Window crosses midnight (e.g., 23:00 - 01:00)
|
|
||||||
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Normal case (e.g., 09:00 - 17:00)
|
|
||||||
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// One-time window: check if current time is within the date range
|
|
||||||
if (now.After(start) || now.Equal(start)) && now.Before(end) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// Check if alert is silenced
|
|
||||||
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
|
||||||
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
|
|||||||
@@ -1,387 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
|
|
||||||
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
|
|
||||||
func TestBatteryAlertLogic(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
|
|
||||||
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Battery",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 20, // threshold: 20%
|
|
||||||
"min": 1, // 1 minute (immediate trigger for testing)
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify alert is not triggered initially
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
|
||||||
|
|
||||||
// Create system stats with battery at 50% (above threshold - should NOT trigger)
|
|
||||||
statsHigh := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{50, 1}, // 50% battery, discharging
|
|
||||||
}
|
|
||||||
statsHighJSON, _ := json.Marshal(statsHigh)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsHighJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create CombinedData for the alert handler
|
|
||||||
combinedDataHigh := &system.CombinedData{
|
|
||||||
Stats: statsHigh,
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate system update time
|
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with high battery
|
|
||||||
am := hub.GetAlertManager()
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)")
|
|
||||||
|
|
||||||
// Now create stats with battery at 15% (below threshold - should trigger)
|
|
||||||
statsLow := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{15, 1}, // 15% battery, discharging
|
|
||||||
}
|
|
||||||
statsLowJSON, _ := json.Marshal(statsLow)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsLowJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
combinedDataLow := &system.CombinedData{
|
|
||||||
Stats: statsLow,
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update system timestamp
|
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with low battery
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert IS triggered (battery 15% is below threshold 20%)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)")
|
|
||||||
|
|
||||||
// Now test resolution: battery goes back above threshold
|
|
||||||
statsRecovered := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{25, 1}, // 25% battery, discharging
|
|
||||||
}
|
|
||||||
statsRecoveredJSON, _ := json.Marshal(statsRecovered)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsRecoveredJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
combinedDataRecovered := &system.CombinedData{
|
|
||||||
Stats: statsRecovered,
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update system timestamp
|
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with recovered battery
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is now resolved (battery 25% is above threshold 20%)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts
|
|
||||||
func TestBatteryAlertNoBattery(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
// Create a battery alert
|
|
||||||
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Battery",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 20,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create stats with NO battery data (Battery[0] = 0)
|
|
||||||
statsNoBattery := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{0, 0}, // No battery
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedData := &system.CombinedData{
|
|
||||||
Stats: statsNoBattery,
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate system update time
|
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts
|
|
||||||
am := hub.GetAlertManager()
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait a moment for processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is NOT triggered (no battery data should skip the alert)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples)
|
|
||||||
// This ensures the inverted threshold logic works correctly across averaged time windows
|
|
||||||
func TestBatteryAlertAveragedSamples(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
|
|
||||||
// Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging)
|
|
||||||
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Battery",
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 25, // threshold: 25%
|
|
||||||
"min": 2, // 2 minutes - requires averaging
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify alert is not triggered initially
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
|
||||||
|
|
||||||
am := hub.GetAlertManager()
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Create system_stats records with low battery (below threshold)
|
|
||||||
// The alert has min=2 minutes, so alert.time = now - 2 minutes
|
|
||||||
// For the alert to be valid, alert.time must be AFTER the oldest record's created time
|
|
||||||
// So we need records older than (now - 2 min), plus records within the window
|
|
||||||
// Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s
|
|
||||||
recordTimes := []time.Duration{
|
|
||||||
-180 * time.Second, // 3 min ago - this makes the oldest record before alert.time
|
|
||||||
-90 * time.Second,
|
|
||||||
-60 * time.Second,
|
|
||||||
-30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, offset := range recordTimes {
|
|
||||||
statsLow := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold)
|
|
||||||
}
|
|
||||||
statsLowJSON, _ := json.Marshal(statsLow)
|
|
||||||
|
|
||||||
recordTime := now.Add(offset)
|
|
||||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsLowJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Update created time to simulate historical records - use SetRaw with formatted string
|
|
||||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create combined data with low battery
|
|
||||||
combinedDataLow := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{15, 1},
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update system timestamp
|
|
||||||
systemRecord.Set("updated", now)
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts - should trigger because average battery is below threshold
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for alert processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert IS triggered (average battery 15% is below threshold 25%)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, batteryAlert.GetBool("triggered"),
|
|
||||||
"Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period")
|
|
||||||
|
|
||||||
// Now add records with high battery to test resolution
|
|
||||||
// Use a new time window 2 minutes later
|
|
||||||
newNow := now.Add(2 * time.Minute)
|
|
||||||
// Records need to span before the alert time window (newNow - 2 min)
|
|
||||||
recordTimesHigh := []time.Duration{
|
|
||||||
-180 * time.Second, // 3 min before newNow - makes oldest record before alert.time
|
|
||||||
-90 * time.Second,
|
|
||||||
-60 * time.Second,
|
|
||||||
-30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, offset := range recordTimesHigh {
|
|
||||||
statsHigh := system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold)
|
|
||||||
}
|
|
||||||
statsHighJSON, _ := json.Marshal(statsHigh)
|
|
||||||
|
|
||||||
recordTime := newNow.Add(offset)
|
|
||||||
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": string(statsHighJSON),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create combined data with high battery
|
|
||||||
combinedDataHigh := &system.CombinedData{
|
|
||||||
Stats: system.Stats{
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
Battery: [2]uint8{50, 1},
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
|
||||||
AgentVersion: "0.12.0",
|
|
||||||
Cpu: 10,
|
|
||||||
MemPct: 30,
|
|
||||||
DiskPct: 40,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update system timestamp to the new time window
|
|
||||||
systemRecord.Set("updated", newNow)
|
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts - should resolve because average battery is now above threshold
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for alert processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is resolved (average battery 50% is above threshold 25%)
|
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, batteryAlert.GetBool("triggered"),
|
|
||||||
"Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period")
|
|
||||||
}
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAlertSilencedOneTime(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Create an alert
|
|
||||||
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"value": 80,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Test that alert is silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced during active one-time window")
|
|
||||||
|
|
||||||
// Create a window that has already ended
|
|
||||||
pastStart := now.Add(-3 * time.Hour)
|
|
||||||
pastEnd := now.Add(-2 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": pastStart,
|
|
||||||
"end": pastEnd,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Should still be silenced because of the first window
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
|
|
||||||
|
|
||||||
// Clear all windows and create a future window
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
futureStart := now.Add(2 * time.Hour)
|
|
||||||
futureEnd := now.Add(3 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": futureStart,
|
|
||||||
"end": futureEnd,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should NOT be silenced (window hasn't started yet)
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
|
|
||||||
|
|
||||||
_ = alert
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedDaily(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Get current hour and create a window that includes current time
|
|
||||||
now := time.Now().UTC()
|
|
||||||
currentHour := now.Hour()
|
|
||||||
currentMin := now.Minute()
|
|
||||||
|
|
||||||
// Create a window from 1 hour ago to 1 hour from now
|
|
||||||
startHour := (currentHour - 1 + 24) % 24
|
|
||||||
endHour := (currentHour + 1) % 24
|
|
||||||
|
|
||||||
// Create times with just the hours/minutes we want (date doesn't matter for daily)
|
|
||||||
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
|
|
||||||
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should be silenced (current time is within the daily window)
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced during active daily window")
|
|
||||||
|
|
||||||
// Clear windows and create one that doesn't include current time
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a window from 6-12 hours from now
|
|
||||||
futureStartHour := (currentHour + 6) % 24
|
|
||||||
futureEndHour := (currentHour + 12) % 24
|
|
||||||
|
|
||||||
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
|
|
||||||
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Alert should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a window that crosses midnight: 22:00 - 02:00
|
|
||||||
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
|
||||||
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "daily",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Test with a time at 23:00 (should be silenced)
|
|
||||||
// We can't control the actual current time, but we can verify the logic
|
|
||||||
// by checking if the window was created correctly
|
|
||||||
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Len(t, windows, 1, "Should have created 1 window")
|
|
||||||
|
|
||||||
window := windows[0]
|
|
||||||
assert.Equal(t, "daily", window.GetString("type"))
|
|
||||||
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
|
|
||||||
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedGlobal(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create multiple systems
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a global quiet hours window (no system specified)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
// system field is empty/null for global windows
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// All systems should be silenced
|
|
||||||
for _, system := range systems {
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Even with a systemID that doesn't exist, should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedSystemSpecific(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create multiple systems
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system1 := systems[0]
|
|
||||||
system2 := systems[1]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a system-specific quiet hours window for system1 only
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system1.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// System1 should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for system1")
|
|
||||||
|
|
||||||
// System2 should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced for system2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedMultiUser(t *testing.T) {
|
|
||||||
hub, _ := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create two users
|
|
||||||
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a system accessible to both users
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "shared-system",
|
|
||||||
"users": []string{user1.Id, user2.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Create a quiet hours window for user1 only
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user1.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// User1 should be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
|
|
||||||
assert.True(t, silenced, "Alert should be silenced for user1")
|
|
||||||
|
|
||||||
// User2 should NOT be silenced
|
|
||||||
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced for user2")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Create a status alert
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status",
|
|
||||||
"system": system.Id,
|
|
||||||
"user": user.Id,
|
|
||||||
"min": 1,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create user settings with email
|
|
||||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
|
||||||
if err != nil || userSettings == nil {
|
|
||||||
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"settings": map[string]any{
|
|
||||||
"emails": []string{"test@example.com"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a quiet hours window
|
|
||||||
now := time.Now().UTC()
|
|
||||||
startTime := now.Add(-1 * time.Hour)
|
|
||||||
endTime := now.Add(1 * time.Hour)
|
|
||||||
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "one-time",
|
|
||||||
"start": startTime,
|
|
||||||
"end": endTime,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Get initial email count
|
|
||||||
initialEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
|
|
||||||
// Trigger an alert by setting system to down
|
|
||||||
system.Set("status", "down")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed (1 minute + buffer)
|
|
||||||
time.Sleep(time.Second * 75)
|
|
||||||
synctest.Wait()
|
|
||||||
|
|
||||||
// Check that no email was sent (because alert is silenced)
|
|
||||||
finalEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
|
|
||||||
|
|
||||||
// Clear quiet hours windows
|
|
||||||
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Reset system to up, then down again
|
|
||||||
system.Set("status", "up")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
system.Set("status", "down")
|
|
||||||
err = hub.SaveNoValidate(system)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(time.Second * 75)
|
|
||||||
synctest.Wait()
|
|
||||||
|
|
||||||
// Now an email should be sent
|
|
||||||
newEmailCount := hub.TestMailer.TotalSend()
|
|
||||||
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertSilencedNoWindows(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
system := systems[0]
|
|
||||||
|
|
||||||
// Get alert manager
|
|
||||||
am := alerts.NewAlertManager(hub)
|
|
||||||
defer am.StopWorker()
|
|
||||||
|
|
||||||
// Without any quiet hours windows, alert should NOT be silenced
|
|
||||||
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
|
||||||
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package alerts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// handleSmartDeviceAlert sends alerts when a SMART device state changes from PASSED to FAILED.
|
|
||||||
// This is automatic and does not require user opt-in.
|
|
||||||
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
|
|
||||||
oldState := e.Record.Original().GetString("state")
|
|
||||||
newState := e.Record.GetString("state")
|
|
||||||
|
|
||||||
// Only alert when transitioning from PASSED to FAILED
|
|
||||||
if oldState != "PASSED" || newState != "FAILED" {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
systemID := e.Record.GetString("system")
|
|
||||||
if systemID == "" {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the system record to get the name and users
|
|
||||||
systemRecord, err := e.App.FindRecordById("systems", systemID)
|
|
||||||
if err != nil {
|
|
||||||
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
systemName := systemRecord.GetString("name")
|
|
||||||
deviceName := e.Record.GetString("name")
|
|
||||||
model := e.Record.GetString("model")
|
|
||||||
|
|
||||||
// Build alert message
|
|
||||||
title := fmt.Sprintf("SMART failure on %s: %s \U0001F534", systemName, deviceName)
|
|
||||||
var message string
|
|
||||||
if model != "" {
|
|
||||||
message = fmt.Sprintf("Disk %s (%s) SMART status changed to FAILED", deviceName, model)
|
|
||||||
} else {
|
|
||||||
message = fmt.Sprintf("Disk %s SMART status changed to FAILED", deviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get users associated with the system
|
|
||||||
userIDs := systemRecord.GetStringSlice("users")
|
|
||||||
if len(userIDs) == 0 {
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send alert to each user
|
|
||||||
for _, userID := range userIDs {
|
|
||||||
if err := am.SendAlert(AlertMessageData{
|
|
||||||
UserID: userID,
|
|
||||||
SystemID: systemID,
|
|
||||||
Title: title,
|
|
||||||
Message: message,
|
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
}); err != nil {
|
|
||||||
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Next()
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
// +build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSmartDeviceAlert(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system for the user
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a smart_device with state PASSED
|
|
||||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "/dev/sda",
|
|
||||||
"model": "Samsung SSD 970 EVO",
|
|
||||||
"state": "PASSED",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify no emails sent initially
|
|
||||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
|
|
||||||
|
|
||||||
// Re-fetch the record so PocketBase can properly track original values
|
|
||||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Update the smart device state to FAILED
|
|
||||||
smartDevice.Set("state", "FAILED")
|
|
||||||
err = hub.Save(smartDevice)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify that an email was sent
|
|
||||||
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
|
|
||||||
|
|
||||||
// Check the email content
|
|
||||||
lastMessage := hub.TestMailer.LastMessage()
|
|
||||||
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
|
|
||||||
assert.Contains(t, lastMessage.Subject, "/dev/sda")
|
|
||||||
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
|
|
||||||
assert.Contains(t, lastMessage.Text, "FAILED")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system for the user
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a smart_device with state UNKNOWN
|
|
||||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "/dev/sda",
|
|
||||||
"model": "Samsung SSD 970 EVO",
|
|
||||||
"state": "UNKNOWN",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Re-fetch the record so PocketBase can properly track original values
|
|
||||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Update the state from UNKNOWN to FAILED - should NOT trigger alert
|
|
||||||
smartDevice.Set("state", "FAILED")
|
|
||||||
err = hub.Save(smartDevice)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify no email was sent (only PASSED -> FAILED triggers alert)
|
|
||||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
|
|
||||||
|
|
||||||
// Re-fetch the record again
|
|
||||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Update state from FAILED to PASSED - should NOT trigger alert
|
|
||||||
smartDevice.Set("state", "PASSED")
|
|
||||||
err = hub.Save(smartDevice)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify no email was sent
|
|
||||||
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
|
|
||||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a second user
|
|
||||||
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create user settings for the second user
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": user2.Id,
|
|
||||||
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a system with both users
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "shared-system",
|
|
||||||
"users": []string{user1.Id, user2.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a smart_device with state PASSED
|
|
||||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "/dev/nvme0n1",
|
|
||||||
"model": "WD Black SN850",
|
|
||||||
"state": "PASSED",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Re-fetch the record so PocketBase can properly track original values
|
|
||||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Update the smart device state to FAILED
|
|
||||||
smartDevice.Set("state", "FAILED")
|
|
||||||
err = hub.Save(smartDevice)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify that two emails were sent (one for each user)
|
|
||||||
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create a system for the user
|
|
||||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
"host": "127.0.0.1",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Create a smart_device with state PASSED but no model
|
|
||||||
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "/dev/sdb",
|
|
||||||
"state": "PASSED",
|
|
||||||
})
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Re-fetch the record so PocketBase can properly track original values
|
|
||||||
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
// Update the smart device state to FAILED
|
|
||||||
smartDevice.Set("state", "FAILED")
|
|
||||||
err = hub.Save(smartDevice)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify that an email was sent
|
|
||||||
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
|
|
||||||
|
|
||||||
// Check that the email doesn't have empty parentheses for missing model
|
|
||||||
lastMessage := hub.TestMailer.LastMessage()
|
|
||||||
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
|
|
||||||
assert.Contains(t, lastMessage.Text, "/dev/sdb")
|
|
||||||
}
|
|
||||||
@@ -161,15 +161,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
// Get system ID for the link
|
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
systemID := alertRecord.GetString("system")
|
// return errs["user"]
|
||||||
|
// }
|
||||||
|
// user := alertRecord.ExpandedOne("user")
|
||||||
|
// if user == nil {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
SystemID: systemID,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,32 +64,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
case "GPU":
|
|
||||||
val = data.Info.GpuPct
|
|
||||||
case "Battery":
|
|
||||||
if data.Stats.Battery[0] == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val = float64(data.Stats.Battery[0])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.GetFloat("value")
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
|
||||||
lowAlert := isLowAlert(name)
|
|
||||||
|
|
||||||
// CONTINUE
|
// CONTINUE
|
||||||
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
|
// IF alert is not triggered and curValue is less than threshold
|
||||||
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
|
// OR alert is triggered and curValue is greater than threshold
|
||||||
if lowAlert {
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
if (!triggered && val >= threshold) || (triggered && val < threshold) {
|
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||||
continue
|
continue
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
@@ -107,11 +92,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
|
|
||||||
// send alert immediately if min is 1 - no need to sum up values.
|
// send alert immediately if min is 1 - no need to sum up values.
|
||||||
if min == 1 {
|
if min == 1 {
|
||||||
if lowAlert {
|
alert.triggered = val > threshold
|
||||||
alert.triggered = val < threshold
|
|
||||||
} else {
|
|
||||||
alert.triggered = val > threshold
|
|
||||||
}
|
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -225,19 +206,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
case "GPU":
|
|
||||||
if len(stats.GPU) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
maxUsage := 0.0
|
|
||||||
for _, gpu := range stats.GPU {
|
|
||||||
if gpu.Usage > maxUsage {
|
|
||||||
maxUsage = gpu.Usage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val += maxUsage
|
|
||||||
case "Battery":
|
|
||||||
alert.val += float64(stats.Battery[0])
|
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -275,24 +243,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
// pass through alert if count is greater than or equal to minCount
|
// pass through alert if count is greater than or equal to minCount
|
||||||
if float32(alert.count) >= minCount {
|
if float32(alert.count) >= minCount {
|
||||||
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
lowAlert := isLowAlert(alert.name)
|
alert.triggered = true
|
||||||
if lowAlert {
|
go am.sendSystemAlert(alert)
|
||||||
if !alert.triggered && alert.val < alert.threshold {
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
alert.triggered = true
|
alert.triggered = false
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
} else if alert.triggered && alert.val >= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
|
||||||
alert.triggered = true
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,26 +268,17 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU or GPU
|
// make title alert name lowercase if not CPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
if titleAlertName != "CPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
lowAlert := isLowAlert(alert.name)
|
|
||||||
if alert.triggered {
|
if alert.triggered {
|
||||||
if lowAlert {
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if lowAlert {
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
minutesLabel := "minute"
|
minutesLabel := "minute"
|
||||||
if alert.min > 1 {
|
if alert.min > 1 {
|
||||||
@@ -349,14 +296,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
SystemID: alert.systemRecord.Id,
|
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLowAlert(name string) bool {
|
|
||||||
return name == "Battery"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
hubURL string // hubURL is the URL of the Beszel hub.
|
// TODO: add hubURL and token
|
||||||
token string // token is the token to use for authentication.
|
// hubURL string // hubURL is the URL of the hub to use.
|
||||||
|
// token string // token is the token to use for authentication.
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
@@ -46,13 +47,13 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
||||||
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
||||||
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
|
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
|
||||||
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
|
||||||
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
help := pflag.BoolP("help", "h", false, "Show this help message")
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
// Convert old single-dash long flags to double-dash for backward compatibility
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
flagsToConvert := []string{"key", "listen", "url", "token"}
|
flagsToConvert := []string{"key", "listen"}
|
||||||
for i, arg := range os.Args {
|
for i, arg := range os.Args {
|
||||||
for _, flag := range flagsToConvert {
|
for _, flag := range flagsToConvert {
|
||||||
singleDash := "-" + flag
|
singleDash := "-" + flag
|
||||||
@@ -94,13 +95,6 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment variables from CLI flags (if provided)
|
|
||||||
if opts.hubURL != "" {
|
|
||||||
os.Setenv("HUB_URL", opts.hubURL)
|
|
||||||
}
|
|
||||||
if opts.token != "" {
|
|
||||||
os.Setenv("TOKEN", opts.token)
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebSocketAction = uint8
|
type WebSocketAction = uint8
|
||||||
@@ -13,14 +11,6 @@ const (
|
|||||||
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
|
|
||||||
// Request detailed systemd service info from agent
|
|
||||||
GetSystemdInfo
|
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,14 +23,10 @@ type HubRequest[T any] struct {
|
|||||||
|
|
||||||
// AgentResponse defines the structure for responses sent from agent to hub.
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
type AgentResponse struct {
|
type AgentResponse struct {
|
||||||
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
||||||
Error string `cbor:"3,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"`
|
|
||||||
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
|
|
||||||
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,18 +44,6 @@ type FingerprintResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DataRequestOptions struct {
|
type DataRequestOptions struct {
|
||||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
IncludeDetails bool `cbor:"1,keyasint"`
|
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerLogsRequest struct {
|
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContainerInfoRequest struct {
|
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SystemdInfoRequest struct {
|
|
||||||
ServiceName string `cbor:"0,keyasint"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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:3.22
|
|
||||||
COPY --from=builder /agent /agent
|
|
||||||
|
|
||||||
RUN apk add --no-cache smartmontools
|
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
|
||||||
@@ -16,11 +16,11 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
|
|||||||
# Final image
|
# Final image
|
||||||
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:3.22
|
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 smartmontools
|
RUN apk add --no-cache -X https://dl-cdn.alpinelinux.org/alpine/edge/testing igt-gpu-tools
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ FROM --platform=$BUILDPLATFORM golang:alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|
||||||
COPY ../go.mod ../go.sum ./
|
COPY ../go.mod ../go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
@@ -12,24 +13,7 @@ COPY . ./
|
|||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
RUN rm -rf /tmp/*
|
||||||
# Smartmontools builder stage
|
|
||||||
# --------------------------
|
|
||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04 AS smartmontools-builder
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
wget \
|
|
||||||
build-essential \
|
|
||||||
&& wget https://downloads.sourceforge.net/project/smartmontools/smartmontools/7.5/smartmontools-7.5.tar.gz \
|
|
||||||
&& tar zxvf smartmontools-7.5.tar.gz \
|
|
||||||
&& cd smartmontools-7.5 \
|
|
||||||
&& ./configure --prefix=/usr --sysconfdir=/etc \
|
|
||||||
&& make \
|
|
||||||
&& make install \
|
|
||||||
&& rm -rf /smartmontools-7.5* \
|
|
||||||
&& apt-get remove -y wget build-essential \
|
|
||||||
&& apt-get autoremove -y \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: GPU-enabled agent with nvidia-smi
|
# Final image: GPU-enabled agent with nvidia-smi
|
||||||
@@ -37,8 +21,8 @@ RUN apt-get update && apt-get install -y \
|
|||||||
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
FROM nvidia/cuda:12.2.2-base-ubuntu22.04
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
# Copy smartmontools binaries and config files
|
# this is so we don't need to create the /tmp directory in the scratch container
|
||||||
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
COPY --from=builder /tmp /tmp
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ type ApiInfo struct {
|
|||||||
IdShort string
|
IdShort string
|
||||||
Names []string
|
Names []string
|
||||||
Status string
|
Status string
|
||||||
State string
|
// Image string
|
||||||
Image string
|
|
||||||
// ImageID string
|
// ImageID string
|
||||||
// Command string
|
// Command string
|
||||||
// Created int64
|
// Created int64
|
||||||
@@ -17,6 +16,7 @@ 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"`
|
||||||
@@ -34,17 +34,6 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker system info from /info
|
|
||||||
type HostInfo struct {
|
|
||||||
OperatingSystem string `json:"OperatingSystem"`
|
|
||||||
KernelVersion string `json:"KernelVersion"`
|
|
||||||
NCPU int `json:"NCPU"`
|
|
||||||
MemTotal uint64 `json:"MemTotal"`
|
|
||||||
// OSVersion string `json:"OSVersion"`
|
|
||||||
// OSType string `json:"OSType"`
|
|
||||||
// Architecture string `json:"Architecture"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
@@ -114,22 +103,6 @@ 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"`
|
||||||
@@ -137,11 +110,6 @@ 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:"-"`
|
||||||
|
|||||||
@@ -1,529 +0,0 @@
|
|||||||
package smart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 SmartRawValue `json:"value"`
|
|
||||||
String string `json:"string"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *RawValue) UnmarshalJSON(data []byte) error {
|
|
||||||
var tmp struct {
|
|
||||||
Value json.RawMessage `json:"value"`
|
|
||||||
String string `json:"string"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tmp.Value) > 0 {
|
|
||||||
if err := r.Value.UnmarshalJSON(tmp.Value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
r.Value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
r.String = tmp.String
|
|
||||||
|
|
||||||
if parsed, ok := ParseSmartRawValueString(tmp.String); ok {
|
|
||||||
r.Value = SmartRawValue(parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartRawValue uint64
|
|
||||||
|
|
||||||
// handles when drives report strings like "0h+0m+0.000s" or "7344 (253d 8h)" for power on hours
|
|
||||||
func (v *SmartRawValue) UnmarshalJSON(data []byte) error {
|
|
||||||
trimmed := strings.TrimSpace(string(data))
|
|
||||||
if len(trimmed) == 0 || trimmed == "null" {
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed[0] == '"' {
|
|
||||||
valueStr, err := strconv.Unquote(trimmed)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsed, ok := ParseSmartRawValueString(valueStr)
|
|
||||||
if ok {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, err := strconv.ParseUint(trimmed, 0, 64); err == nil {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, ok := ParseSmartRawValueString(trimmed); ok {
|
|
||||||
*v = SmartRawValue(parsed)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
*v = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseSmartRawValueString attempts to extract a numeric value from the raw value
|
|
||||||
// strings emitted by smartctl, which sometimes include human-friendly annotations
|
|
||||||
// like "7344 (253d 8h)" or "0h+0m+0.000s". It returns the parsed value and a
|
|
||||||
// boolean indicating success.
|
|
||||||
func ParseSmartRawValueString(value string) (uint64, bool) {
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed, err := strconv.ParseUint(value, 0, 64); err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if idx := strings.IndexRune(value, 'h'); idx > 0 {
|
|
||||||
hoursPart := strings.TrimSpace(value[:idx])
|
|
||||||
if hoursPart != "" {
|
|
||||||
if parsed, err := strconv.ParseFloat(hoursPart, 64); err == nil {
|
|
||||||
return uint64(parsed), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
if value[i] < '0' || value[i] > '9' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
end := i + 1
|
|
||||||
for end < len(value) && value[end] >= '0' && value[end] <= '9' {
|
|
||||||
end++
|
|
||||||
}
|
|
||||||
digits := value[i:end]
|
|
||||||
if parsed, err := strconv.ParseUint(digits, 10, 64); err == nil {
|
|
||||||
return parsed, true
|
|
||||||
}
|
|
||||||
i = end
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// type PowerOnTimeInfo struct {
|
|
||||||
// Hours uint32 `json:"hours"`
|
|
||||||
// }
|
|
||||||
|
|
||||||
type TemperatureInfo struct {
|
|
||||||
Current uint8 `json:"current"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TemperatureInfoScsi struct {
|
|
||||||
Current uint8 `json:"current"`
|
|
||||||
DriveTrip uint8 `json:"drive_trip"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"`
|
|
||||||
ScsiVendor string `json:"scsi_vendor"`
|
|
||||||
ScsiProduct string `json:"scsi_product"`
|
|
||||||
// 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 ScsiErrorCounter struct {
|
|
||||||
ErrorsCorrectedByECCFast uint64 `json:"errors_corrected_by_eccfast"`
|
|
||||||
ErrorsCorrectedByECCDelayed uint64 `json:"errors_corrected_by_eccdelayed"`
|
|
||||||
ErrorsCorrectedByRereadsRewrites uint64 `json:"errors_corrected_by_rereads_rewrites"`
|
|
||||||
TotalErrorsCorrected uint64 `json:"total_errors_corrected"`
|
|
||||||
CorrectionAlgorithmInvocations uint64 `json:"correction_algorithm_invocations"`
|
|
||||||
GigabytesProcessed string `json:"gigabytes_processed"`
|
|
||||||
TotalUncorrectedErrors uint64 `json:"total_uncorrected_errors"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScsiErrorCounterLog struct {
|
|
||||||
Read ScsiErrorCounter `json:"read"`
|
|
||||||
Write ScsiErrorCounter `json:"write"`
|
|
||||||
Verify ScsiErrorCounter `json:"verify"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScsiStartStopCycleCounter struct {
|
|
||||||
YearOfManufacture string `json:"year_of_manufacture"`
|
|
||||||
WeekOfManufacture string `json:"week_of_manufacture"`
|
|
||||||
SpecifiedCycleCountOverDeviceLifetime uint64 `json:"specified_cycle_count_over_device_lifetime"`
|
|
||||||
AccumulatedStartStopCycles uint64 `json:"accumulated_start_stop_cycles"`
|
|
||||||
SpecifiedLoadUnloadCountOverDeviceLifetime uint64 `json:"specified_load_unload_count_over_device_lifetime"`
|
|
||||||
AccumulatedLoadUnloadCycles uint64 `json:"accumulated_load_unload_cycles"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PowerOnTimeScsi struct {
|
|
||||||
Hours uint64 `json:"hours"`
|
|
||||||
Minutes uint64 `json:"minutes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SmartInfoForScsi struct {
|
|
||||||
Smartctl SmartctlInfoLegacy `json:"smartctl"`
|
|
||||||
Device DeviceInfo `json:"device"`
|
|
||||||
ScsiVendor string `json:"scsi_vendor"`
|
|
||||||
ScsiProduct string `json:"scsi_product"`
|
|
||||||
ScsiModelName string `json:"scsi_model_name"`
|
|
||||||
ScsiRevision string `json:"scsi_revision"`
|
|
||||||
ScsiVersion string `json:"scsi_version"`
|
|
||||||
SerialNumber string `json:"serial_number"`
|
|
||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
|
||||||
Temperature TemperatureInfoScsi `json:"temperature"`
|
|
||||||
SmartStatus SmartStatusInfo `json:"smart_status"`
|
|
||||||
PowerOnTime PowerOnTimeScsi `json:"power_on_time"`
|
|
||||||
ScsiStartStopCycleCounter ScsiStartStopCycleCounter `json:"scsi_start_stop_cycle_counter"`
|
|
||||||
ScsiGrownDefectList uint64 `json:"scsi_grown_defect_list"`
|
|
||||||
ScsiErrorCounterLog ScsiErrorCounterLog `json:"scsi_error_counter_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"`
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package smart
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDuration(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"62312h+33m+50.907s","string":"62312h+33m+50.907s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 62312, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalNumericString(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"7344","string":"7344"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 7344, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalParenthetical(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"39925 (212 206 0)","string":"39925 (212 206 0)"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 39925, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDurationWithFractions(t *testing.T) {
|
|
||||||
input := []byte(`{"value":"2748h+31m+49.560s","string":"2748h+31m+49.560s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 2748, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalParentheticalRawValue(t *testing.T) {
|
|
||||||
input := []byte(`{"value":57891864217128,"string":"39925 (212 206 0)"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 39925, raw.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSmartRawValueUnmarshalDurationRawValue(t *testing.T) {
|
|
||||||
input := []byte(`{"value":57891864217128,"string":"2748h+31m+49.560s"}`)
|
|
||||||
var raw RawValue
|
|
||||||
err := json.Unmarshal(input, &raw)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.EqualValues(t, 2748, raw.Value)
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,9 @@ package system
|
|||||||
// TODO: this is confusing, make common package with common/types common/helpers etc
|
// TODO: this is confusing, make common package with common/types common/helpers etc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
@@ -43,28 +41,9 @@ type Stats struct {
|
|||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"28,keyasint"`
|
||||||
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]
|
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]
|
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||||
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
|
|
||||||
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
|
||||||
// JSON: encodes as array of numbers (avoids base64 string).
|
|
||||||
// CBOR: falls back to default handling for []uint8 (byte string), keeping payload small.
|
|
||||||
type Uint8Slice []uint8
|
|
||||||
|
|
||||||
func (s Uint8Slice) MarshalJSON() ([]byte, error) {
|
|
||||||
if s == nil {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
// Convert to wider ints to force array-of-numbers encoding.
|
|
||||||
arr := make([]uint16, len(s))
|
|
||||||
for i, v := range s {
|
|
||||||
arr[i] = uint16(v)
|
|
||||||
}
|
|
||||||
return json.Marshal(arr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -123,55 +102,34 @@ const (
|
|||||||
ConnectionTypeWebSocket
|
ConnectionTypeWebSocket
|
||||||
)
|
)
|
||||||
|
|
||||||
// Core system data that is needed in All Systems table
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
|
Hostname string `json:"h" cbor:"0,keyasint"`
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
|
Cores int `json:"c" cbor:"2,keyasint"`
|
||||||
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
|
|
||||||
// Threads is needed in Info struct to calculate load average thresholds
|
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
CpuModel string `json:"m" cbor:"4,keyasint"`
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
|
Os Os `json:"os" cbor:"14,keyasint"`
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
||||||
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
|
||||||
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
|
||||||
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data that does not change during process lifetime and is not needed in All Systems table
|
|
||||||
type Details struct {
|
|
||||||
Hostname string `cbor:"0,keyasint"`
|
|
||||||
Kernel string `cbor:"1,keyasint,omitempty"`
|
|
||||||
Cores int `cbor:"2,keyasint"`
|
|
||||||
Threads int `cbor:"3,keyasint"`
|
|
||||||
CpuModel string `cbor:"4,keyasint"`
|
|
||||||
Os Os `cbor:"5,keyasint"`
|
|
||||||
OsName string `cbor:"6,keyasint"`
|
|
||||||
Arch string `cbor:"7,keyasint"`
|
|
||||||
Podman bool `cbor:"8,keyasint,omitempty"`
|
|
||||||
MemoryTotal uint64 `cbor:"9,keyasint"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
type CombinedData struct {
|
type CombinedData struct {
|
||||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package systemd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceState represents the status of a systemd service
|
|
||||||
type ServiceState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive ServiceState = iota
|
|
||||||
StatusInactive
|
|
||||||
StatusFailed
|
|
||||||
StatusActivating
|
|
||||||
StatusDeactivating
|
|
||||||
StatusReloading
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceSubState represents the sub status of a systemd service
|
|
||||||
type ServiceSubState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
SubStateDead ServiceSubState = iota
|
|
||||||
SubStateRunning
|
|
||||||
SubStateExited
|
|
||||||
SubStateFailed
|
|
||||||
SubStateUnknown
|
|
||||||
)
|
|
||||||
|
|
||||||
// ParseServiceStatus converts a string status to a ServiceStatus enum value
|
|
||||||
func ParseServiceStatus(status string) ServiceState {
|
|
||||||
switch status {
|
|
||||||
case "active":
|
|
||||||
return StatusActive
|
|
||||||
case "inactive":
|
|
||||||
return StatusInactive
|
|
||||||
case "failed":
|
|
||||||
return StatusFailed
|
|
||||||
case "activating":
|
|
||||||
return StatusActivating
|
|
||||||
case "deactivating":
|
|
||||||
return StatusDeactivating
|
|
||||||
case "reloading":
|
|
||||||
return StatusReloading
|
|
||||||
default:
|
|
||||||
return StatusInactive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
|
|
||||||
func ParseServiceSubState(subState string) ServiceSubState {
|
|
||||||
switch subState {
|
|
||||||
case "dead":
|
|
||||||
return SubStateDead
|
|
||||||
case "running":
|
|
||||||
return SubStateRunning
|
|
||||||
case "exited":
|
|
||||||
return SubStateExited
|
|
||||||
case "failed":
|
|
||||||
return SubStateFailed
|
|
||||||
default:
|
|
||||||
return SubStateUnknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service represents a single systemd service with its stats.
|
|
||||||
type Service struct {
|
|
||||||
Name string `json:"n" cbor:"0,keyasint"`
|
|
||||||
State ServiceState `json:"s" cbor:"1,keyasint"`
|
|
||||||
Cpu float64 `json:"c" cbor:"2,keyasint"`
|
|
||||||
Mem uint64 `json:"m" cbor:"3,keyasint"`
|
|
||||||
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
|
|
||||||
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
|
|
||||||
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
|
|
||||||
PrevCpuUsage uint64 `json:"-"`
|
|
||||||
PrevReadTime time.Time `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCPUPercent calculates the CPU usage percentage for the service.
|
|
||||||
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
|
|
||||||
s.Cpu = 0
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := now.Sub(s.PrevReadTime).Nanoseconds()
|
|
||||||
if duration <= 0 {
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
coreCount := int64(runtime.NumCPU())
|
|
||||||
duration *= coreCount
|
|
||||||
|
|
||||||
usageDelta := cpuUsage - s.PrevCpuUsage
|
|
||||||
cpuPercent := float64(usageDelta) / float64(duration)
|
|
||||||
s.Cpu = twoDecimals(cpuPercent * 100)
|
|
||||||
|
|
||||||
if s.Cpu > s.CpuPeak {
|
|
||||||
s.CpuPeak = s.Cpu
|
|
||||||
}
|
|
||||||
|
|
||||||
s.PrevCpuUsage = cpuUsage
|
|
||||||
s.PrevReadTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoDecimals(value float64) float64 {
|
|
||||||
return math.Round(value*100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceDependency represents a unit that the service depends on.
|
|
||||||
type ServiceDependency struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
ActiveState string `json:"activeState,omitempty"`
|
|
||||||
SubState string `json:"subState,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceDetails contains extended information about a systemd service.
|
|
||||||
type ServiceDetails map[string]any
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package systemd_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseServiceStatus(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected systemd.ServiceState
|
|
||||||
}{
|
|
||||||
{"active", systemd.StatusActive},
|
|
||||||
{"inactive", systemd.StatusInactive},
|
|
||||||
{"failed", systemd.StatusFailed},
|
|
||||||
{"activating", systemd.StatusActivating},
|
|
||||||
{"deactivating", systemd.StatusDeactivating},
|
|
||||||
{"reloading", systemd.StatusReloading},
|
|
||||||
{"unknown", systemd.StatusInactive}, // default case
|
|
||||||
{"", systemd.StatusInactive}, // default case
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := systemd.ParseServiceStatus(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseServiceSubState(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected systemd.ServiceSubState
|
|
||||||
}{
|
|
||||||
{"dead", systemd.SubStateDead},
|
|
||||||
{"running", systemd.SubStateRunning},
|
|
||||||
{"exited", systemd.SubStateExited},
|
|
||||||
{"failed", systemd.SubStateFailed},
|
|
||||||
{"unknown", systemd.SubStateUnknown},
|
|
||||||
{"other", systemd.SubStateUnknown}, // default case
|
|
||||||
{"", systemd.SubStateUnknown}, // default case
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.input, func(t *testing.T) {
|
|
||||||
result := systemd.ParseServiceSubState(test.input)
|
|
||||||
assert.Equal(t, test.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceUpdateCPUPercent(t *testing.T) {
|
|
||||||
t.Run("initial call sets CPU to 0", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.UpdateCPUPercent(1000)
|
|
||||||
assert.Equal(t, 0.0, service.Cpu)
|
|
||||||
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
|
|
||||||
assert.False(t, service.PrevReadTime.IsZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
|
|
||||||
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
|
|
||||||
|
|
||||||
// CPU usage should be positive and reasonable
|
|
||||||
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
|
|
||||||
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
|
|
||||||
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
|
|
||||||
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("CPU peak updates only when higher", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
|
|
||||||
initialPeak := service.CpuPeak
|
|
||||||
|
|
||||||
// Now try with much lower CPU usage - should not update peak
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(1000000) // Much lower usage
|
|
||||||
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles zero duration", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
now := time.Now()
|
|
||||||
service.PrevReadTime = now
|
|
||||||
// Mock time.Now() to return the same time to ensure zero duration
|
|
||||||
// Since we can't mock time in Go easily, we'll check the logic manually
|
|
||||||
// The zero duration case happens when duration <= 0
|
|
||||||
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("handles CPU usage wraparound", func(t *testing.T) {
|
|
||||||
service := &systemd.Service{}
|
|
||||||
// Simulate wraparound where new usage is less than previous
|
|
||||||
service.PrevCpuUsage = 1000
|
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
|
||||||
service.UpdateCPUPercent(500) // Less than previous, should reset
|
|
||||||
assert.Equal(t, 0.0, service.Cpu)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -120,27 +120,18 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// set auth settings
|
// set auth settings
|
||||||
if err := setCollectionAuthSettings(e.App); err != nil {
|
usersCollection, err := e.App.FindCollectionByNameOrId("users")
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
superusersCollection, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||||
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'"
|
||||||
@@ -148,52 +139,29 @@ func setCollectionAuthSettings(app core.App) 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
|
return err
|
||||||
}
|
}
|
||||||
if err := app.Save(usersCollection); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
|
||||||
|
|
||||||
// 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 := app.FindCollectionByNameOrId("systems")
|
systemsCollection, err := e.App.FindCachedCollectionByNameOrId("systems")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var systemsReadRule string
|
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
||||||
if shareAllSystems == "true" {
|
systemsReadRule := "@request.auth.id != \"\""
|
||||||
systemsReadRule = "@request.auth.id != \"\""
|
if shareAllSystems != "true" {
|
||||||
} else {
|
// default is to only show systems that the user id is assigned to
|
||||||
systemsReadRule = "@request.auth.id != \"\" && users.id ?= @request.auth.id"
|
systemsReadRule += " && users.id ?= @request.auth.id"
|
||||||
}
|
}
|
||||||
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
|
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
|
||||||
systemsCollection.ListRule = &systemsReadRule
|
systemsCollection.ListRule = &systemsReadRule
|
||||||
systemsCollection.ViewRule = &systemsReadRule
|
systemsCollection.ViewRule = &systemsReadRule
|
||||||
systemsCollection.UpdateRule = &updateDeleteRule
|
systemsCollection.UpdateRule = &updateDeleteRule
|
||||||
systemsCollection.DeleteRule = &updateDeleteRule
|
systemsCollection.DeleteRule = &updateDeleteRule
|
||||||
if err := app.Save(systemsCollection); err != nil {
|
if err := e.App.Save(systemsCollection); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
// allow all users to access all containers if SHARE_ALL_SYSTEMS is set
|
|
||||||
containersCollection, err := app.FindCollectionByNameOrId("containers")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
containersListRule := strings.Replace(systemsReadRule, "users.id", "system.users.id", 1)
|
|
||||||
containersCollection.ListRule = &containersListRule
|
|
||||||
return app.Save(containersCollection)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerCronJobs sets up scheduled tasks
|
// registerCronJobs sets up scheduled tasks
|
||||||
@@ -268,17 +236,7 @@ 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)
|
||||||
// refresh SMART devices for a system
|
|
||||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
|
||||||
// get systemd service details
|
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
|
||||||
// /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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,83 +267,6 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getSystemdInfo handles GET /api/beszel/systemd/info requests
|
|
||||||
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|
||||||
query := e.Request.URL.Query()
|
|
||||||
systemID := query.Get("system")
|
|
||||||
serviceName := query.Get("service")
|
|
||||||
|
|
||||||
if systemID == "" || serviceName == "" {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
|
|
||||||
}
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
|
||||||
}
|
|
||||||
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
|
||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
|
||||||
// Fetches fresh SMART data from the agent and updates the collection
|
|
||||||
func (h *Hub) refreshSmartData(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"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch and save SMART devices
|
|
||||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
|
||||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,47 +449,6 @@ 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,45 +5,37 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"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/internal/entities/systemd"
|
|
||||||
|
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type System struct {
|
type System struct {
|
||||||
Id string `db:"id"`
|
Id string `db:"id"`
|
||||||
Host string `db:"host"`
|
Host string `db:"host"`
|
||||||
Port string `db:"port"`
|
Port string `db:"port"`
|
||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
manager *SystemManager // Manager that this system belongs to
|
manager *SystemManager // Manager that this system belongs to
|
||||||
client *ssh.Client // SSH client for fetching data
|
client *ssh.Client // SSH client for fetching data
|
||||||
data *system.CombinedData // system data from agent
|
data *system.CombinedData // system data from agent
|
||||||
ctx context.Context // Context for stopping the updater
|
ctx context.Context // Context for stopping the updater
|
||||||
cancel context.CancelFunc // Stops and removes system from updater
|
cancel context.CancelFunc // Stops and removes system from updater
|
||||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
agentVersion semver.Version // Agent version
|
agentVersion semver.Version // Agent version
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
|
||||||
smartFetched atomic.Bool // True if SMART devices have been fetched and saved
|
|
||||||
smartFetching atomic.Bool // True if SMART devices are currently being fetched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -116,14 +108,7 @@ func (sys *System) update() error {
|
|||||||
sys.handlePaused()
|
sys.handlePaused()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
options := common.DataRequestOptions{
|
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
|
||||||
CacheTimeMs: uint16(interval),
|
|
||||||
}
|
|
||||||
// fetch system details if not already fetched
|
|
||||||
if !sys.detailsFetched.Load() {
|
|
||||||
options.IncludeDetails = true
|
|
||||||
}
|
|
||||||
data, err := sys.fetchDataFromAgent(options)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = sys.createRecords(data)
|
_, err = sys.createRecords(data)
|
||||||
}
|
}
|
||||||
@@ -150,164 +135,41 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hub := sys.manager.hub
|
hub := sys.manager.hub
|
||||||
err = hub.RunInTransaction(func(txApp core.App) error {
|
// add system_stats and container_stats records
|
||||||
// add system_stats record
|
systemStatsCollection, err := hub.FindCachedCollectionByNameOrId("system_stats")
|
||||||
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
|
if err != nil {
|
||||||
|
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 err
|
return nil, err
|
||||||
}
|
}
|
||||||
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
containerStatsRecord := core.NewRecord(containerStatsCollection)
|
||||||
systemStatsRecord.Set("system", systemRecord.Id)
|
containerStatsRecord.Set("system", systemRecord.Id)
|
||||||
systemStatsRecord.Set("stats", data.Stats)
|
containerStatsRecord.Set("stats", data.Containers)
|
||||||
systemStatsRecord.Set("type", "1m")
|
containerStatsRecord.Set("type", "1m")
|
||||||
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
|
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
// add containers and container_stats records
|
|
||||||
if len(data.Containers) > 0 {
|
|
||||||
if data.Containers[0].Id != "" {
|
|
||||||
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add new systemd_stats record
|
|
||||||
if len(data.SystemdServices) > 0 {
|
|
||||||
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add system details record
|
|
||||||
if data.Details != nil {
|
|
||||||
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
sys.detailsFetched.Store(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch and save SMART devices when system first comes online
|
|
||||||
if err == nil {
|
|
||||||
if !sys.smartFetched.Load() && sys.smartFetching.CompareAndSwap(false, true) {
|
|
||||||
go func() {
|
|
||||||
defer sys.smartFetching.Store(false)
|
|
||||||
if err := sys.FetchAndSaveSmartDevices(); err == nil {
|
|
||||||
sys.smartFetched.Store(true)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
return systemRecord, err
|
systemRecord.Set("info", data.Info)
|
||||||
}
|
if err := hub.SaveNoValidate(systemRecord); err != nil {
|
||||||
|
return nil, err
|
||||||
func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error {
|
|
||||||
collectionName := "system_details"
|
|
||||||
params := dbx.Params{
|
|
||||||
"id": systemId,
|
|
||||||
"system": systemId,
|
|
||||||
"hostname": data.Hostname,
|
|
||||||
"kernel": data.Kernel,
|
|
||||||
"cores": data.Cores,
|
|
||||||
"threads": data.Threads,
|
|
||||||
"cpu": data.CpuModel,
|
|
||||||
"os": data.Os,
|
|
||||||
"os_name": data.OsName,
|
|
||||||
"arch": data.Arch,
|
|
||||||
"memory": data.MemoryTotal,
|
|
||||||
"podman": data.Podman,
|
|
||||||
"updated": time.Now().UTC(),
|
|
||||||
}
|
}
|
||||||
result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute()
|
return systemRecord, nil
|
||||||
rowsAffected, _ := result.RowsAffected()
|
|
||||||
if err != nil || rowsAffected == 0 {
|
|
||||||
_, err = app.DB().Insert(collectionName, params).Execute()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// shared params for all records
|
|
||||||
params := dbx.Params{
|
|
||||||
"system": systemId,
|
|
||||||
"updated": time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
valueStrings := make([]string, 0, len(data))
|
|
||||||
for i, service := range data {
|
|
||||||
suffix := fmt.Sprintf("%d", i)
|
|
||||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
|
||||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
|
||||||
params["name"+suffix] = service.Name
|
|
||||||
params["state"+suffix] = service.State
|
|
||||||
params["sub"+suffix] = service.Sub
|
|
||||||
params["cpu"+suffix] = service.Cpu
|
|
||||||
params["cpuPeak"+suffix] = service.CpuPeak
|
|
||||||
params["memory"+suffix] = service.Mem
|
|
||||||
params["memPeak"+suffix] = service.MemPeak
|
|
||||||
}
|
|
||||||
queryString := fmt.Sprintf(
|
|
||||||
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
|
|
||||||
strings.Join(valueStrings, ","),
|
|
||||||
)
|
|
||||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// createContainerRecords creates container records
|
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// shared params for all records
|
|
||||||
params := dbx.Params{
|
|
||||||
"system": systemId,
|
|
||||||
"updated": time.Now().UTC().UnixMilli(),
|
|
||||||
}
|
|
||||||
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.
|
||||||
@@ -380,214 +242,83 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
|||||||
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
|
|
||||||
}
|
|
||||||
reqDataBytes, _ := cbor.Marshal(requestData)
|
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: action, Data: reqDataBytes}
|
|
||||||
_ = 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
|
||||||
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
reqDataBytes, _ := cbor.Marshal(common.SystemdInfoRequest{ServiceName: serviceName})
|
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: common.GetSystemdInfo, Data: reqDataBytes}
|
|
||||||
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.ServiceInfo == nil {
|
|
||||||
if resp.Error != "" {
|
|
||||||
return false, errors.New(resp.Error)
|
|
||||||
}
|
|
||||||
return false, errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
result = resp.ServiceInfo
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeStableHashId(strings ...string) string {
|
|
||||||
hash := fnv.New32a()
|
|
||||||
for _, str := range strings {
|
|
||||||
hash.Write([]byte(str))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%x", hash.Sum32())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataViaSSH(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
maxRetries := 1
|
||||||
|
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 false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
stdin, stdinErr := session.StdinPipe()
|
stdin, stdinErr := session.StdinPipe()
|
||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
*sys.data = system.CombinedData{}
|
*sys.data = system.CombinedData{}
|
||||||
|
|
||||||
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
if sys.agentVersion.GTE(beszel.MinVersionAgentResponse) && stdinErr == nil {
|
||||||
reqDataBytes, _ := cbor.Marshal(options)
|
req := common.HubRequest[any]{Action: common.GetData, Data: options}
|
||||||
req := common.HubRequest[cbor.RawMessage]{Action: common.GetData, Data: reqDataBytes}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
_ = cbor.NewEncoder(stdin).Encode(req)
|
||||||
|
// Close write side to signal end of request
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
|
|
||||||
var resp common.AgentResponse
|
var resp common.AgentResponse
|
||||||
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
if decErr := cbor.NewDecoder(stdout).Decode(&resp); decErr == nil && resp.SystemData != nil {
|
||||||
*sys.data = *resp.SystemData
|
*sys.data = *resp.SystemData
|
||||||
|
// wait for the session to complete
|
||||||
if err := session.Wait(); err != nil {
|
if err := session.Wait(); err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return false, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
// If decoding failed, fall back below
|
||||||
}
|
}
|
||||||
|
|
||||||
var decodeErr error
|
|
||||||
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
if sys.agentVersion.GTE(beszel.MinVersionCbor) {
|
||||||
decodeErr = cbor.NewDecoder(stdout).Decode(sys.data)
|
err = cbor.NewDecoder(stdout).Decode(sys.data)
|
||||||
} else {
|
} else {
|
||||||
decodeErr = json.NewDecoder(stdout).Decode(sys.data)
|
err = json.NewDecoder(stdout).Decode(sys.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
if decodeErr != nil {
|
|
||||||
return true, decodeErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Wait(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return sys.data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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()
|
sys.closeSSHConnection()
|
||||||
continue
|
if attempt < maxRetries {
|
||||||
}
|
|
||||||
|
|
||||||
retry, opErr := func() (bool, error) {
|
|
||||||
defer session.Close()
|
|
||||||
return operation(session)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if opErr == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if retry {
|
|
||||||
sys.closeSSHConnection()
|
|
||||||
if attempt < retries {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return opErr
|
// wait for the session to complete
|
||||||
|
if err := session.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("ssh operation failed")
|
// this should never be reached due to the return in the loop
|
||||||
|
return nil, fmt.Errorf("failed to fetch data")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createSSHClient creates a new SSH client for the system
|
// createSSHClient creates a new SSH client for the system
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ 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.
|
||||||
|
|||||||
@@ -154,20 +154,19 @@ func (sm *SystemManager) startRealtimeWorker() {
|
|||||||
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
// fetchRealtimeDataAndNotify fetches realtime data for all active subscriptions and notifies the clients.
|
||||||
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
func (sm *SystemManager) fetchRealtimeDataAndNotify() {
|
||||||
for systemId, info := range activeSubscriptions {
|
for systemId, info := range activeSubscriptions {
|
||||||
system, err := sm.GetSystem(systemId)
|
system, ok := sm.systems.GetOk(systemId)
|
||||||
if err != nil {
|
if ok {
|
||||||
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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, 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]smart.SmartData
|
|
||||||
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
|
|
||||||
}
|
|
||||||
result = resp.SmartData
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
|
||||||
if err != nil || len(smartData) == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return sys.saveSmartDevices(smartData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
|
||||||
func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error {
|
|
||||||
if len(smartData) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
hub := sys.manager.hub
|
|
||||||
collection, err := hub.FindCachedCollectionByNameOrId("smart_devices")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
for deviceKey, device := range smartData {
|
|
||||||
if err := sys.upsertSmartDeviceRecord(collection, deviceKey, device); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
|
||||||
hub := sys.manager.hub
|
|
||||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
|
||||||
|
|
||||||
record, err := hub.FindRecordById(collection, recordID)
|
|
||||||
if err != nil {
|
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
record = core.NewRecord(collection)
|
|
||||||
record.Set("id", recordID)
|
|
||||||
}
|
|
||||||
|
|
||||||
name := device.DiskName
|
|
||||||
if name == "" {
|
|
||||||
name = deviceKey
|
|
||||||
}
|
|
||||||
|
|
||||||
powerOnHours, powerCycles := extractPowerMetrics(device.Attributes)
|
|
||||||
record.Set("system", sys.Id)
|
|
||||||
record.Set("name", name)
|
|
||||||
record.Set("model", device.ModelName)
|
|
||||||
record.Set("state", device.SmartStatus)
|
|
||||||
record.Set("capacity", device.Capacity)
|
|
||||||
record.Set("temp", device.Temperature)
|
|
||||||
record.Set("firmware", device.FirmwareVersion)
|
|
||||||
record.Set("serial", device.SerialNumber)
|
|
||||||
record.Set("type", device.DiskType)
|
|
||||||
record.Set("hours", powerOnHours)
|
|
||||||
record.Set("cycles", powerCycles)
|
|
||||||
record.Set("attributes", device.Attributes)
|
|
||||||
|
|
||||||
return hub.SaveNoValidate(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractPowerMetrics extracts power on hours and power cycles from SMART attributes
|
|
||||||
func extractPowerMetrics(attributes []*smart.SmartAttribute) (powerOnHours, powerCycles uint64) {
|
|
||||||
for _, attr := range attributes {
|
|
||||||
nameLower := strings.ToLower(attr.Name)
|
|
||||||
if powerOnHours == 0 && (strings.Contains(nameLower, "poweronhours") || strings.Contains(nameLower, "power_on_hours")) {
|
|
||||||
powerOnHours = attr.RawValue
|
|
||||||
}
|
|
||||||
if powerCycles == 0 && ((strings.Contains(nameLower, "power") && strings.Contains(nameLower, "cycle")) || strings.Contains(nameLower, "startstopcycles")) {
|
|
||||||
powerCycles = attr.RawValue
|
|
||||||
}
|
|
||||||
if powerOnHours > 0 && powerCycles > 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package systems
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetSystemdServiceId(t *testing.T) {
|
|
||||||
t.Run("deterministic output", func(t *testing.T) {
|
|
||||||
systemId := "sys-123"
|
|
||||||
serviceName := "nginx.service"
|
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
|
||||||
id1 := makeStableHashId(systemId, serviceName)
|
|
||||||
id2 := makeStableHashId(systemId, serviceName)
|
|
||||||
id3 := makeStableHashId(systemId, serviceName)
|
|
||||||
|
|
||||||
assert.Equal(t, id1, id2)
|
|
||||||
assert.Equal(t, id2, id3)
|
|
||||||
assert.NotEmpty(t, id1)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("different inputs produce different ids", func(t *testing.T) {
|
|
||||||
systemId1 := "sys-123"
|
|
||||||
systemId2 := "sys-456"
|
|
||||||
serviceName1 := "nginx.service"
|
|
||||||
serviceName2 := "apache.service"
|
|
||||||
|
|
||||||
id1 := makeStableHashId(systemId1, serviceName1)
|
|
||||||
id2 := makeStableHashId(systemId2, serviceName1)
|
|
||||||
id3 := makeStableHashId(systemId1, serviceName2)
|
|
||||||
id4 := makeStableHashId(systemId2, serviceName2)
|
|
||||||
|
|
||||||
// All IDs should be different
|
|
||||||
assert.NotEqual(t, id1, id2)
|
|
||||||
assert.NotEqual(t, id1, id3)
|
|
||||||
assert.NotEqual(t, id1, id4)
|
|
||||||
assert.NotEqual(t, id2, id3)
|
|
||||||
assert.NotEqual(t, id2, id4)
|
|
||||||
assert.NotEqual(t, id3, id4)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("consistent length", func(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
systemId string
|
|
||||||
serviceName string
|
|
||||||
}{
|
|
||||||
{"short", "short.service"},
|
|
||||||
{"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
|
|
||||||
{"", "empty-system.service"},
|
|
||||||
{"empty-service", ""},
|
|
||||||
{"", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
id := makeStableHashId(tc.systemId, tc.serviceName)
|
|
||||||
// FNV-32 produces 8 hex characters
|
|
||||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("hexadecimal output", func(t *testing.T) {
|
|
||||||
id := makeStableHashId("test-system", "test-service")
|
|
||||||
assert.NotEmpty(t, id)
|
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
|
||||||
for _, char := range id {
|
|
||||||
assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
|
|
||||||
"ID should only contain hexadecimal characters, got: %s", id)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -266,20 +266,18 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
|||||||
|
|
||||||
// Create test system data
|
// Create test system data
|
||||||
testData := &system.CombinedData{
|
testData := &system.CombinedData{
|
||||||
Details: &system.Details{
|
|
||||||
Hostname: "data-test.example.com",
|
|
||||||
Kernel: "5.15.0-generic",
|
|
||||||
Cores: 4,
|
|
||||||
Threads: 8,
|
|
||||||
CpuModel: "Test CPU",
|
|
||||||
},
|
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Uptime: 3600,
|
Hostname: "data-test.example.com",
|
||||||
Cpu: 25.5,
|
KernelVersion: "5.15.0-generic",
|
||||||
MemPct: 40.2,
|
Cores: 4,
|
||||||
DiskPct: 60.0,
|
Threads: 8,
|
||||||
Bandwidth: 100.0,
|
CpuModel: "Test CPU",
|
||||||
AgentVersion: "1.0.0",
|
Uptime: 3600,
|
||||||
|
Cpu: 25.5,
|
||||||
|
MemPct: 40.2,
|
||||||
|
DiskPct: 60.0,
|
||||||
|
Bandwidth: 100.0,
|
||||||
|
AgentVersion: "1.0.0",
|
||||||
},
|
},
|
||||||
Stats: system.Stats{
|
Stats: system.Stats{
|
||||||
Cpu: 25.5,
|
Cpu: 25.5,
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
@@ -20,11 +18,11 @@ type ResponseHandler interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
// BaseHandler provides a default implementation that can be embedded to make HandleLegacy optional
|
||||||
type BaseHandler struct{}
|
// type BaseHandler struct{}
|
||||||
|
|
||||||
func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
// func (h *BaseHandler) HandleLegacy(rawData []byte) error {
|
||||||
return errors.New("legacy format not supported")
|
// return errors.New("legacy format not supported")
|
||||||
}
|
// }
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -65,131 +63,6 @@ func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedDa
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
handler := &systemdInfoHandler{result: &result}
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemdInfoHandler parses ServiceDetails from AgentResponse
|
|
||||||
type systemdInfoHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *systemd.ServiceDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.ServiceInfo == nil {
|
|
||||||
return errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.ServiceInfo
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, 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]smart.SmartData
|
|
||||||
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]smart.SmartData
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.SmartData == nil {
|
|
||||||
return errors.New("no SMART data in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.SmartData
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
type fingerprintHandler struct {
|
type fingerprintHandler struct {
|
||||||
result *common.FingerprintResponse
|
result *common.FingerprintResponse
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerSuccess(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test successful handling with valid ServiceInfo
|
|
||||||
testDetails := systemd.ServiceDetails{
|
|
||||||
"Id": "nginx.service",
|
|
||||||
"ActiveState": "active",
|
|
||||||
"SubState": "running",
|
|
||||||
"Description": "A high performance web server",
|
|
||||||
"ExecMainPID": 1234,
|
|
||||||
"MemoryCurrent": 1024000,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: testDetails,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testDetails, *handler.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerError(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test error handling when ServiceInfo is nil
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: nil,
|
|
||||||
Error: "service not found",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with completely empty response
|
|
||||||
response := common.AgentResponse{}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that legacy format is not supported
|
|
||||||
err := handler.HandleLegacy([]byte("some data"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "legacy format not supported", err.Error())
|
|
||||||
}
|
|
||||||
@@ -181,17 +181,6 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
@@ -75,11 +76,9 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
"GPU",
|
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15",
|
"LoadAvg15"
|
||||||
"Battery"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -720,9 +719,7 @@ func init() {
|
|||||||
"type": "autodate"
|
"type": "autodate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [
|
"indexes": [],
|
||||||
"CREATE INDEX ` + "`" + `idx_systems_status` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `status` + "`" + `)"
|
|
||||||
],
|
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -863,760 +860,6 @@ 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
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": null,
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{10}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 10,
|
|
||||||
"min": 6,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text1579384326",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number2063623452",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "state",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1476559580",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "sub",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3128971310",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cpu",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1052053287",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cpuPeak",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3933025333",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "memory",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1828797201",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "memPeak",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3332085495",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "updated",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_3494996990",
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
|
|
||||||
"CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"name": "systemd_services",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": null,
|
|
||||||
"viewRule": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{10}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 10,
|
|
||||||
"min": 10,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation2375276105",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "user",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "select2844932856",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"name": "type",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "select",
|
|
||||||
"values": [
|
|
||||||
"one-time",
|
|
||||||
"daily"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "date2675529103",
|
|
||||||
"max": "",
|
|
||||||
"min": "",
|
|
||||||
"name": "start",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "date"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "date16528305",
|
|
||||||
"max": "",
|
|
||||||
"min": "",
|
|
||||||
"name": "end",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "date"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_451525641",
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)",
|
|
||||||
"CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"name": "quiet_hours",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
|
|
||||||
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": null,
|
|
||||||
"deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{10}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 10,
|
|
||||||
"min": 10,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"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": "text3616895705",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "model",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text2744374011",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "state",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3051925876",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "capacity",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number190023114",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "temp",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3589068740",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "firmware",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3547646428",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "serial",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text2363381545",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "type",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1234567890",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "hours",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number0987654321",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cycles",
|
|
||||||
"onlyInt": true,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "json832282224",
|
|
||||||
"maxSize": 0,
|
|
||||||
"name": "attributes",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_2571630677",
|
|
||||||
"indexes": [
|
|
||||||
"CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)"
|
|
||||||
],
|
|
||||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"name": "smart_devices",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": null,
|
|
||||||
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"createRule": "",
|
|
||||||
"deleteRule": "",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "[a-z0-9]{15}",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3208210256",
|
|
||||||
"max": 15,
|
|
||||||
"min": 15,
|
|
||||||
"name": "id",
|
|
||||||
"pattern": "^[a-z0-9]+$",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": true,
|
|
||||||
"required": true,
|
|
||||||
"system": true,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"collectionId": "2hz5ncl8tizk5nx",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "relation3377271179",
|
|
||||||
"maxSelect": 1,
|
|
||||||
"minSelect": 0,
|
|
||||||
"name": "system",
|
|
||||||
"presentable": false,
|
|
||||||
"required": true,
|
|
||||||
"system": false,
|
|
||||||
"type": "relation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3847340049",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "hostname",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1789936913",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "os",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text2818598173",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "os_name",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text1574083243",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "kernel",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text3128971310",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "cpu",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"autogeneratePattern": "",
|
|
||||||
"hidden": false,
|
|
||||||
"id": "text4161937994",
|
|
||||||
"max": 0,
|
|
||||||
"min": 0,
|
|
||||||
"name": "arch",
|
|
||||||
"pattern": "",
|
|
||||||
"presentable": false,
|
|
||||||
"primaryKey": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number4245036687",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "cores",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number1871592925",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "threads",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "number3933025333",
|
|
||||||
"max": null,
|
|
||||||
"min": null,
|
|
||||||
"name": "memory",
|
|
||||||
"onlyInt": false,
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "bool2200265312",
|
|
||||||
"name": "podman",
|
|
||||||
"presentable": false,
|
|
||||||
"required": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "bool"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"hidden": false,
|
|
||||||
"id": "autodate3332085495",
|
|
||||||
"name": "updated",
|
|
||||||
"onCreate": true,
|
|
||||||
"onUpdate": true,
|
|
||||||
"presentable": false,
|
|
||||||
"system": false,
|
|
||||||
"type": "autodate"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id": "pbc_3116237454",
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
|
||||||
"name": "system_details",
|
|
||||||
"system": false,
|
|
||||||
"type": "base",
|
|
||||||
"updateRule": "",
|
|
||||||
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
|
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -1625,6 +868,31 @@ 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
|
||||||
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
50
internal/migrations/1758738789_fix_cached_mem.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
m "github.com/pocketbase/pocketbase/migrations"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This can be deleted after Nov 2025 or so
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
m.Register(func(app core.App) error {
|
||||||
|
app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
var systemIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
|
||||||
|
|
||||||
|
for _, systemId := range systemIds {
|
||||||
|
var statRecordIds []string
|
||||||
|
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
|
||||||
|
|
||||||
|
for _, statRecordId := range statRecordIds {
|
||||||
|
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var systemStats system.Stats
|
||||||
|
err = statRecord.UnmarshalJSONField("stats", &systemStats)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// if mem buff cache is less than total mem, we don't need to fix it
|
||||||
|
if systemStats.MemBuffCache < systemStats.Mem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
systemStats.MemBuffCache = 0
|
||||||
|
statRecord.Set("stats", systemStats)
|
||||||
|
err = txApp.SaveNoValidate(statRecord)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}, func(app core.App) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -177,10 +177,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
stats := &tempStats
|
stats := &tempStats
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
// accumulate per-core usage across records
|
|
||||||
var cpuCoresSums []uint64
|
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
|
||||||
var cpuBreakdownSums []float64
|
|
||||||
|
|
||||||
count := float64(len(records))
|
count := float64(len(records))
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
@@ -198,15 +194,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
}
|
}
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
|
||||||
if stats.CpuBreakdown != nil {
|
|
||||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
|
||||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
|
||||||
}
|
|
||||||
for i, v := range stats.CpuBreakdown {
|
|
||||||
cpuBreakdownSums[i] += v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
sum.MemUsed += stats.MemUsed
|
sum.MemUsed += stats.MemUsed
|
||||||
sum.MemPct += stats.MemPct
|
sum.MemPct += stats.MemPct
|
||||||
@@ -230,17 +217,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.DiskIO[1] += stats.DiskIO[1]
|
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]
|
||||||
|
|
||||||
// accumulate per-core usage if present
|
|
||||||
if stats.CpuCoresUsage != nil {
|
|
||||||
if len(cpuCoresSums) < len(stats.CpuCoresUsage) {
|
|
||||||
// extend slices to accommodate core count
|
|
||||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
|
||||||
}
|
|
||||||
for i, v := range stats.CpuCoresUsage {
|
|
||||||
cpuCoresSums[i] += uint64(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set peak values
|
// Set peak values
|
||||||
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
|
||||||
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
sum.MaxMem = max(sum.MaxMem, stats.MaxMem, stats.MemUsed)
|
||||||
@@ -293,10 +269,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
fs.DiskReadPs += value.DiskReadPs
|
fs.DiskReadPs += value.DiskReadPs
|
||||||
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
|
||||||
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
|
||||||
fs.DiskReadBytes += value.DiskReadBytes
|
|
||||||
fs.DiskWriteBytes += value.DiskWriteBytes
|
|
||||||
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
|
|
||||||
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +356,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
|
||||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,25 +379,6 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.GPUData[id] = gpu
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Average per-core usage
|
|
||||||
if len(cpuCoresSums) > 0 {
|
|
||||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
|
||||||
for i := range cpuCoresSums {
|
|
||||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
|
||||||
avg[i] = uint8(v)
|
|
||||||
}
|
|
||||||
sum.CpuCoresUsage = avg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average CPU breakdown
|
|
||||||
if len(cpuBreakdownSums) > 0 {
|
|
||||||
avg := make([]float64, len(cpuBreakdownSums))
|
|
||||||
for i := range cpuBreakdownSums {
|
|
||||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
|
||||||
}
|
|
||||||
sum.CpuBreakdown = avg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum
|
return sum
|
||||||
@@ -486,22 +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 = deleteOldSystemdServiceRecords(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = deleteOldQuietHours(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -567,45 +506,6 @@ func deleteOldSystemStats(app core.App) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
|
||||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
|
||||||
|
|
||||||
// Delete systemd service records where updated < twentyMinutesAgo
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes container records that haven't been updated in the last 10 minutes
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes old quiet hours records where end date has passed
|
|
||||||
func deleteOldQuietHours(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return 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
|
||||||
|
|||||||
@@ -351,83 +351,6 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
|
||||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
rm := records.NewRecordManager(hub)
|
|
||||||
|
|
||||||
// Create test user and system
|
|
||||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
|
||||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "nginx.service",
|
|
||||||
"state": 0, // Active
|
|
||||||
"sub": 1, // Running
|
|
||||||
"cpu": 5.0,
|
|
||||||
"cpuPeak": 10.0,
|
|
||||||
"memory": 1024000,
|
|
||||||
"memPeak": 2048000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 25 minutes ago (should be deleted)
|
|
||||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(oldRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
|
||||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "apache.service",
|
|
||||||
"state": 1, // Inactive
|
|
||||||
"sub": 0, // Dead
|
|
||||||
"cpu": 2.0,
|
|
||||||
"cpuPeak": 3.0,
|
|
||||||
"memory": 512000,
|
|
||||||
"memPeak": 1024000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 10 minutes ago (should be kept)
|
|
||||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(recentRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Count records before deletion
|
|
||||||
countBefore, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
|
||||||
|
|
||||||
// Run deletion via RecordManager
|
|
||||||
rm.DeleteOldRecords()
|
|
||||||
|
|
||||||
// Count records after deletion
|
|
||||||
countAfter, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
|
||||||
|
|
||||||
// Verify the correct record was kept
|
|
||||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
|
||||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecordManagerCreation tests RecordManager creation
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
func TestRecordManagerCreation(t *testing.T) {
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
|||||||
@@ -17,9 +17,6 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
"a11y": {
|
|
||||||
"useButtonType": "off"
|
|
||||||
},
|
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noUselessStringConcat": "error",
|
"noUselessStringConcat": "error",
|
||||||
"noUselessUndefinedInitialization": "error",
|
"noUselessUndefinedInitialization": "error",
|
||||||
@@ -33,14 +30,13 @@
|
|||||||
"noUnusedFunctionParameters": "error",
|
"noUnusedFunctionParameters": "error",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedPrivateClassMembers": "error",
|
||||||
"useExhaustiveDependencies": {
|
"useExhaustiveDependencies": {
|
||||||
"level": "off"
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"reportUnnecessaryDependencies": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"useUniqueElementIds": "off",
|
|
||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
},
|
},
|
||||||
"security": {
|
|
||||||
"noDangerouslySetInnerHtml": "warn"
|
|
||||||
},
|
|
||||||
"style": {
|
"style": {
|
||||||
"noParameterProperties": "error",
|
"noParameterProperties": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
@@ -51,8 +47,7 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"useAwait": "error",
|
"useAwait": "error",
|
||||||
"noEvolvingTypes": "error",
|
"noEvolvingTypes": "error"
|
||||||
"noArrayIndexKey": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
BIN
internal/site/bun.lockb
Executable file
BIN
internal/site/bun.lockb
Executable file
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en" dir="ltr">
|
<html lang="en" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="./static/manifest.json" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/icon.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" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export default defineConfig({
|
|||||||
"es",
|
"es",
|
||||||
"fa",
|
"fa",
|
||||||
"fr",
|
"fr",
|
||||||
"he",
|
|
||||||
"hr",
|
"hr",
|
||||||
"hu",
|
"hu",
|
||||||
"it",
|
"it",
|
||||||
|
"is",
|
||||||
"ja",
|
"ja",
|
||||||
"ko",
|
"ko",
|
||||||
"nl",
|
"nl",
|
||||||
@@ -24,7 +24,6 @@ export default defineConfig({
|
|||||||
"tr",
|
"tr",
|
||||||
"ru",
|
"ru",
|
||||||
"sl",
|
"sl",
|
||||||
"sr",
|
|
||||||
"sv",
|
"sv",
|
||||||
"uk",
|
"uk",
|
||||||
"vi",
|
"vi",
|
||||||
|
|||||||
1080
internal/site/package-lock.json
generated
1080
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.18.0-beta.1",
|
"version": "0.13.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -46,15 +46,14 @@
|
|||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.1.2",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.2",
|
"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.4",
|
"@biomejs/biome": "2.2.3",
|
||||||
"@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,90 +0,0 @@
|
|||||||
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>
|
|
||||||
) : info.invert ? (
|
|
||||||
<Trans>
|
|
||||||
Below {alert.value}
|
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
|
||||||
</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("")])
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { msg, t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
@@ -36,9 +36,6 @@ import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/i
|
|||||||
import { InputCopy } from "./ui/input-copy"
|
import { InputCopy } from "./ui/input-copy"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
if (isReadOnlyUser()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const opened = useRef(false)
|
const opened = useRef(false)
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -48,7 +45,10 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
||||||
|
>
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
<Trans>
|
<Trans>
|
||||||
Add <span className="hidden sm:inline">System</span>
|
Add <span className="hidden sm:inline">System</span>
|
||||||
@@ -124,8 +124,6 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemTranslation = t`System`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
||||||
@@ -136,11 +134,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
||||||
{system ? (
|
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
||||||
<Trans>Edit {{ foo: systemTranslation }}</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
|
<SheetContent className="max-h-full overflow-auto w-145 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -245,23 +245,13 @@ export function AlertContent({
|
|||||||
{!singleDescription && (
|
{!singleDescription && (
|
||||||
<div>
|
<div>
|
||||||
<p id={`v${name}`} className="text-sm block h-8">
|
<p id={`v${name}`} className="text-sm block h-8">
|
||||||
{alertData.invert ? (
|
<Trans>
|
||||||
<Trans>
|
Average exceeds{" "}
|
||||||
Average drops below{" "}
|
<strong className="text-foreground">
|
||||||
<strong className="text-foreground">
|
{value}
|
||||||
{value}
|
{alertData.unit}
|
||||||
{alertData.unit}
|
</strong>
|
||||||
</strong>
|
</Trans>
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>
|
|
||||||
Average exceeds{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{value}
|
|
||||||
{alertData.unit}
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -11,14 +11,12 @@ import {
|
|||||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
import { AxisDomain } from "recharts/types/util/types"
|
|
||||||
|
|
||||||
export type DataPoint = {
|
export type DataPoint = {
|
||||||
label: string
|
label: string
|
||||||
dataKey: (data: SystemStatsRecord) => number | undefined
|
dataKey: (data: SystemStatsRecord) => number | undefined
|
||||||
color: number | string
|
color: number | string
|
||||||
opacity: number
|
opacity: number
|
||||||
stackId?: string | number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AreaChartDefault({
|
export default function AreaChartDefault({
|
||||||
@@ -32,24 +30,20 @@ export default function AreaChartDefault({
|
|||||||
legend,
|
legend,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
showTotal = false,
|
showTotal = false,
|
||||||
reverseStackOrder = false,
|
|
||||||
hideYAxis = false,
|
|
||||||
}: // logRender = false,
|
}: // logRender = false,
|
||||||
{
|
{
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
max?: number
|
max?: number
|
||||||
maxToggled?: boolean
|
maxToggled?: boolean
|
||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
domain?: AxisDomain
|
domain?: [number, number]
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
showTotal?: boolean
|
itemSorter?: (a: any, b: any) => number
|
||||||
itemSorter?: (a: any, b: any) => number
|
showTotal?: boolean
|
||||||
reverseStackOrder?: boolean
|
// logRender?: boolean
|
||||||
hideYAxis?: boolean
|
}) {
|
||||||
// logRender?: boolean
|
|
||||||
}) {
|
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
|
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
|
||||||
@@ -64,29 +58,21 @@ export default function AreaChartDefault({
|
|||||||
<div>
|
<div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
|
||||||
"opacity-100": yAxisWidth || hideYAxis,
|
"opacity-100": yAxisWidth,
|
||||||
"ps-4": hideYAxis,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart
|
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
||||||
reverseStackOrder={reverseStackOrder}
|
|
||||||
accessibilityLayer
|
|
||||||
data={chartData.systemStats}
|
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
|
||||||
>
|
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{!hideYAxis && (
|
<YAxis
|
||||||
<YAxis
|
direction="ltr"
|
||||||
direction="ltr"
|
orientation={chartData.orientation}
|
||||||
orientation={chartData.orientation}
|
className="tracking-tighter"
|
||||||
className="tracking-tighter"
|
width={yAxisWidth}
|
||||||
width={yAxisWidth}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
domain={domain ?? [0, max ?? "auto"]}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
tickLine={false}
|
||||||
tickLine={false}
|
axisLine={false}
|
||||||
axisLine={false}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
@@ -116,11 +102,10 @@ export default function AreaChartDefault({
|
|||||||
fillOpacity={dataPoint.opacity}
|
fillOpacity={dataPoint.opacity}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
stackId={dataPoint.stackId}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
|
{legend && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
|
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
||||||
import { ChartType, Unit } from "@/lib/enums"
|
import { ChartType, Unit } from "@/lib/enums"
|
||||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||||
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
@@ -41,7 +41,7 @@ export default memo(function ContainerChart({
|
|||||||
// tick formatter
|
// tick formatter
|
||||||
if (chartType === ChartType.CPU) {
|
if (chartType === ChartType.CPU) {
|
||||||
obj.tickFormatter = (value) => {
|
obj.tickFormatter = (value) => {
|
||||||
const val = `${toFixedFloat(value, 2)}%`
|
const val = toFixedFloat(value, 2) + unit
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +78,7 @@ export default memo(function ContainerChart({
|
|||||||
return `${decimalString(value)} ${unit}`
|
return `${decimalString(value)} ${unit}`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
|
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
|
||||||
}
|
}
|
||||||
// data function
|
// data function
|
||||||
if (isNetChart) {
|
if (isNetChart) {
|
||||||
@@ -94,11 +94,8 @@ export default memo(function ContainerChart({
|
|||||||
if (!filter) {
|
if (!filter) {
|
||||||
return new Set<string>()
|
return new Set<string>()
|
||||||
}
|
}
|
||||||
const filterTerms = filter.toLowerCase().split(" ").filter(term => term.length > 0)
|
const filterLower = filter.toLowerCase()
|
||||||
return new Set(Object.keys(chartConfig).filter((key) => {
|
return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower)))
|
||||||
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())
|
||||||
@@ -124,7 +121,6 @@ export default memo(function ContainerChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
domain={pinnedAxisDomain()}
|
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
|
|||||||
const hue = ((i * 360) / count) % 360
|
const hue = ((i * 360) / count) % 360
|
||||||
chartConfig[containerName] = {
|
chartConfig[containerName] = {
|
||||||
label: containerName,
|
label: containerName,
|
||||||
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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={["auto", "auto"]}
|
domain={[0, "auto"]}
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
@@ -91,8 +91,7 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{colors.map((key) => {
|
{colors.map((key) => {
|
||||||
const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
|
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||||
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
|
||||||
@@ -114,4 +113,4 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import { DialogDescription } from "@radix-ui/react-dialog"
|
|||||||
import {
|
import {
|
||||||
AlertOctagonIcon,
|
AlertOctagonIcon,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
ContainerIcon,
|
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
HardDriveIcon,
|
LayoutDashboard,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
ServerIcon,
|
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -88,40 +86,14 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerIcon className="me-2 size-4" />
|
<LayoutDashboard className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>All Systems</Trans>
|
<Trans>Dashboard</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
<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
|
|
||||||
onSelect={() => {
|
|
||||||
navigate(getPagePath($router, "smart"))
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HardDriveIcon className="me-2 size-4" />
|
|
||||||
<span>S.M.A.R.T.</span>
|
|
||||||
<CommandShortcut>
|
|
||||||
<Trans>Page</Trans>
|
|
||||||
</CommandShortcut>
|
|
||||||
</CommandItem>
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
|
||||||
import type { 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import {
|
|
||||||
type ColumnFiltersState,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getFilteredRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
type Row,
|
|
||||||
type SortingState,
|
|
||||||
type Table as TableType,
|
|
||||||
useReactTable,
|
|
||||||
type VisibilityState,
|
|
||||||
} from "@tanstack/react-table"
|
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
|
||||||
import { 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 { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } 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 loadTime = Date.now()
|
|
||||||
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
|
||||||
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(() => {
|
|
||||||
function fetchData(systemId?: string) {
|
|
||||||
pb.collection<ContainerRecord>("containers")
|
|
||||||
.getList(0, 2000, {
|
|
||||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
|
||||||
})
|
|
||||||
.then(
|
|
||||||
({ items }) => {
|
|
||||||
if (items.length === 0) {
|
|
||||||
setData([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setData((curItems) => {
|
|
||||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
|
||||||
const containerIds = new Set()
|
|
||||||
const newItems = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
|
||||||
containerIds.add(item.id)
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const item of curItems ?? []) {
|
|
||||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
|
||||||
newItems.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newItems
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// initial load
|
|
||||||
fetchData(systemId)
|
|
||||||
|
|
||||||
// if no systemId, pull system containers after every system update
|
|
||||||
if (!systemId) {
|
|
||||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
|
||||||
// exclude initial load of systems
|
|
||||||
if (Date.now() - loadTime > 500) {
|
|
||||||
fetchData(systemId)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// if systemId, fetch containers after the system is updated
|
|
||||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
|
||||||
fetchData(systemId)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: 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>
|
|
||||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
|
||||||
<Input
|
|
||||||
placeholder={t`Filter...`}
|
|
||||||
value={globalFilter}
|
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
|
||||||
className="ps-4 pe-10 w-full"
|
|
||||||
/>
|
|
||||||
{globalFilter && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t`Clear`}
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
|
||||||
onClick={() => setGlobalFilter("")}
|
|
||||||
>
|
|
||||||
<XIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<div className="rounded-md">
|
|
||||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const AllContainersTable = memo(function AllContainersTable({
|
|
||||||
table,
|
|
||||||
rows,
|
|
||||||
colLength,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
table: TableType<ContainerRecord>
|
|
||||||
rows: Row<ContainerRecord>[]
|
|
||||||
colLength: number
|
|
||||||
data: ContainerRecord[] | undefined
|
|
||||||
}) {
|
|
||||||
// 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">
|
|
||||||
{data ? (
|
|
||||||
<Trans>No results.</Trans>
|
|
||||||
) : (
|
|
||||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
|
||||||
)}
|
|
||||||
</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-white 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-white 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={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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 sm:flex">
|
<Button variant={"ghost"} size="icon" className="hidden 450: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,27 +1,16 @@
|
|||||||
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 font from Google Fonts
|
// Righteous
|
||||||
<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={id} x1="0%" y1="20%" x2="100%" y2="120%">
|
<linearGradient id="gradient" x1="0%" y1="20%" x2="100%" y2="120%">
|
||||||
<stop offset="10%" style={{ stopColor: "#747bff" }} />
|
<stop offset="0%" style={{ stopColor: "#747bff" }} />
|
||||||
<stop offset="90%" style={{ stopColor: "#24eb5c" }} />
|
<stop offset="100%" style={{ stopColor: "#24eb5c" }} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs> */}
|
||||||
<path
|
<path
|
||||||
className="duration-250 group-hover:opacity-0 group-hover:ease-in ease-out"
|
// fill="url(#gradient)"
|
||||||
d={d}
|
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"
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-0 duration-250 group-hover:opacity-100 ease-in-out"
|
|
||||||
fill={`url(#${id})`}
|
|
||||||
d={d}
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +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,
|
||||||
HardDriveIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -30,7 +28,6 @@ import { LangToggle } from "./lang-toggle"
|
|||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||||
import { t } from "@lingui/core/macro"
|
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
@@ -42,7 +39,7 @@ export default function Navbar() {
|
|||||||
<Link
|
<Link
|
||||||
href={basePath}
|
href={basePath}
|
||||||
aria-label="Home"
|
aria-label="Home"
|
||||||
className="p-2 ps-0 me-3 group"
|
className="p-2 ps-0 me-3"
|
||||||
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" />
|
||||||
@@ -50,32 +47,18 @@ 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>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "smart")}
|
|
||||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
|
||||||
aria-label="S.M.A.R.T."
|
|
||||||
>
|
|
||||||
<HardDriveIcon 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>
|
||||||
@@ -129,7 +112,7 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AddSystemButton className="ms-2 hidden 450:flex" />
|
<AddSystemButton className="ms-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { createRouter } from "@nanostores/router"
|
|||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
|
||||||
smart: "/smart",
|
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
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,28 +1,128 @@
|
|||||||
import { useLingui } from "@lingui/react/macro"
|
import { Plural, Trans, 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 { ActiveAlerts } from "@/components/active-alerts"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
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`All Systems`} / Beszel`
|
document.title = `${t`Dashboard`} / Beszel`
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-4">
|
<ActiveAlerts />
|
||||||
<ActiveAlerts />
|
<Suspense>
|
||||||
<Suspense>
|
<SystemsTable />
|
||||||
<SystemsTable />
|
</Suspense>
|
||||||
</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", { id: systems[alert.system]?.id })}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}, [alertsKey.join("")])
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
type PaginationState,
|
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
@@ -41,7 +40,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { alertInfo } from "@/lib/alerts"
|
import { alertInfo } from "@/lib/alerts"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
|
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
|
||||||
import type { AlertsHistoryRecord } from "@/types"
|
import type { AlertsHistoryRecord } from "@/types"
|
||||||
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||||
|
|
||||||
@@ -67,12 +66,6 @@ export default function AlertsHistoryDataTable() {
|
|||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
// Store pagination preference in local storage
|
|
||||||
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
@@ -143,14 +136,12 @@ export default function AlertsHistoryDataTable() {
|
|||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
onPaginationChange: setPagination,
|
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
pagination,
|
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
@@ -327,10 +318,10 @@ export default function AlertsHistoryDataTable() {
|
|||||||
<Select
|
<Select
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
table.setPageSize(Number(value));
|
table.setPageSize(Number(value))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-18" id="rows-per-page">
|
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top">
|
<SelectContent side="top">
|
||||||
|
|||||||
@@ -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="size-4.5 stroke-destructive" />
|
<AlertCircleIcon className="h-4 w-4 stroke-destructive" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Caution - potential data loss</Trans>
|
<Trans>Caution - potential data loss</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import Slider from "@/components/ui/slider"
|
|
||||||
import { HourFormat, Unit } from "@/lib/enums"
|
import { HourFormat, Unit } from "@/lib/enums"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
import { $userSettings } from "@/lib/stores"
|
|
||||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
@@ -20,8 +17,6 @@ import { saveSettings } from "./layout"
|
|||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
const currentUserSettings = useStore($userSettings)
|
|
||||||
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -78,27 +73,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="mb-2">
|
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
|
||||||
<Trans>Layout width</Trans>
|
|
||||||
</h3>
|
|
||||||
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
|
|
||||||
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
id="layoutWidth"
|
|
||||||
name="layoutWidth"
|
|
||||||
value={[layoutWidth]}
|
|
||||||
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
|
|
||||||
min={1000}
|
|
||||||
max={2000}
|
|
||||||
step={10}
|
|
||||||
className="w-full mb-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user