mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-27 16:06:16 +01:00
Compare commits
118 Commits
battery-ch
...
4c9b00a066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c9b00a066 | ||
|
|
7d1f8bb180 | ||
|
|
3a6caeb06e | ||
|
|
9e67245e60 | ||
|
|
b7a95d5d76 | ||
|
|
fe550c5901 | ||
|
|
8aac0a571a | ||
|
|
c506b8b0ad | ||
|
|
a6e84c207e | ||
|
|
249402eaed | ||
|
|
394c476f2a | ||
|
|
86e8a141ea | ||
|
|
53a7e06dcf | ||
|
|
11edabd09f | ||
|
|
41a3d9359f | ||
|
|
5dfc5f247f | ||
|
|
9804c8a31a | ||
|
|
4d05bfdff0 | ||
|
|
0388401a9e | ||
|
|
162c548010 | ||
|
|
888b4a57e5 | ||
|
|
26d367b188 | ||
|
|
ca4988951f | ||
|
|
c7a50dd74d | ||
|
|
00fbf5c9c3 | ||
|
|
4bfe9dd5ad | ||
|
|
e159a75b79 | ||
|
|
a69686125e | ||
|
|
3eb025ded2 | ||
|
|
1d0e646094 | ||
|
|
32c8e047e3 | ||
|
|
3650482b09 | ||
|
|
79adfd2c0d | ||
|
|
779dcc62aa | ||
|
|
abe39c1a0a | ||
|
|
bd41ad813c | ||
|
|
77fe63fb63 | ||
|
|
f61ba202d8 | ||
|
|
e1067fa1a3 | ||
|
|
0a3eb898ae | ||
|
|
6c33e9dc93 | ||
|
|
f8ed6ce705 | ||
|
|
f64478b75e | ||
|
|
854a3697d7 | ||
|
|
b7915b9d0e | ||
|
|
4443b606f6 | ||
|
|
6c20a98651 | ||
|
|
b722ccc5bc | ||
|
|
db0315394b | ||
|
|
a7ef1235f4 | ||
|
|
f64a361c60 | ||
|
|
aaa788bc2f | ||
|
|
3eede6bead | ||
|
|
02abfbcb54 | ||
|
|
01d20562f0 | ||
|
|
cbe6ee6499 | ||
|
|
9a61ea8356 | ||
|
|
1af7ff600f | ||
|
|
02d594cc82 | ||
|
|
7d0b5c1c67 | ||
|
|
d3dc8a7af0 | ||
|
|
d67fefe7c5 | ||
|
|
4d364c5e4d | ||
|
|
954400ea45 | ||
|
|
04b6067e64 | ||
|
|
d77ee5554f | ||
|
|
2e034bdead | ||
|
|
fc0947aa04 | ||
|
|
1d546a4091 | ||
|
|
f60b3bbbfb | ||
|
|
8e99b9f1ad | ||
|
|
fa5ed2bc11 | ||
|
|
21d961ab97 | ||
|
|
aaa93b84d2 | ||
|
|
6a562ce03b | ||
|
|
3dbc48727e | ||
|
|
85ac2e5e9a | ||
|
|
af6bd4e505 | ||
|
|
e54c4b3499 | ||
|
|
078c88f825 | ||
|
|
85169b6c5e | ||
|
|
d0ff8ee2c0 | ||
|
|
e898768997 | ||
|
|
0f5b504f23 | ||
|
|
365d291393 | ||
|
|
3dbab24c0f | ||
|
|
1f67fb7c8d | ||
|
|
219e09fc78 | ||
|
|
cd9c2bd9ab | ||
|
|
9f969d843c | ||
|
|
b22a6472fc | ||
|
|
d231ace28e | ||
|
|
473cb7f437 | ||
|
|
783ed9f456 | ||
|
|
9a9a89ee50 | ||
|
|
5122d0341d | ||
|
|
81731689da | ||
|
|
b3e9857448 | ||
|
|
2eda9eb0e3 | ||
|
|
82a5df5048 | ||
|
|
f11564a7ac | ||
|
|
9df4d29236 | ||
|
|
1452817423 | ||
|
|
c57e496f5e | ||
|
|
6287f7003c | ||
|
|
37037b1f4e | ||
|
|
7cf123a99e | ||
|
|
97394e775f | ||
|
|
d5c381188b | ||
|
|
b107d12a62 | ||
|
|
e646f2c1fc | ||
|
|
b18528d24a | ||
|
|
a6e64df399 | ||
|
|
66ba21dd41 | ||
|
|
1851e7a111 | ||
|
|
74b78e96b3 | ||
|
|
a9657f9c00 | ||
|
|
1dee63a0eb |
42
.github/workflows/docker-images.yml
vendored
42
.github/workflows/docker-images.yml
vendored
@@ -10,6 +10,7 @@ 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
|
# henrygd/beszel
|
||||||
@@ -24,19 +25,18 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=alpine
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}-alpine
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}-alpine
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia
|
# henrygd/beszel-agent-nvidia
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
@@ -66,18 +66,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel
|
# ghcr.io/henrygd/beszel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
@@ -99,6 +87,7 @@ jobs:
|
|||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
|
type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -144,6 +133,19 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-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
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
17
.github/workflows/inactivity-actions.yml
vendored
17
.github/workflows/inactivity-actions.yml
vendored
@@ -10,12 +10,25 @@ 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@v9
|
uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -32,6 +45,8 @@ 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,6 +5,7 @@ project_name: beszel
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
|
- go generate -run fetchsmartctl ./agent
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: beszel
|
- id: beszel
|
||||||
@@ -15,10 +16,21 @@ 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
|
||||||
@@ -85,6 +97,9 @@ 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
|
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -46,8 +46,14 @@ 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
|
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
|
||||||
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
|
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,6 +12,7 @@ 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"
|
||||||
@@ -29,6 +30,8 @@ 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
|
||||||
@@ -43,6 +46,7 @@ type Agent struct {
|
|||||||
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
|
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.
|
||||||
@@ -68,6 +72,16 @@ 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) {
|
||||||
@@ -101,6 +115,11 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager(agent)
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
|
agent.systemdManager, err = newSystemdManager()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("Systemd", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
agent.smartManager, err = NewSmartManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("SMART", "err", err)
|
slog.Debug("SMART", "err", err)
|
||||||
@@ -154,7 +173,20 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
|
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||||
|
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
|
||||||
@@ -163,6 +195,11 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
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)
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ package battery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
|
||||||
"github.com/distatus/battery"
|
"github.com/distatus/battery"
|
||||||
)
|
)
|
||||||
|
|
||||||
var systemHasBattery = false
|
var (
|
||||||
var haveCheckedBattery = false
|
systemHasBattery = 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 {
|
||||||
@@ -21,22 +22,20 @@ func HasReadableBattery() bool {
|
|||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
haveCheckedBattery = true
|
haveCheckedBattery = true
|
||||||
batteries,err := battery.GetAll()
|
batteries, err := battery.GetAll()
|
||||||
if err != nil {
|
for _, bat := range batteries {
|
||||||
// even if there's errors getting some batteries, the system
|
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
||||||
// definitely has a battery if the list is not empty.
|
systemHasBattery = true
|
||||||
// This list will include everything `battery` can find,
|
break
|
||||||
// including things like bluetooth devices.
|
}
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
}
|
}
|
||||||
systemHasBattery = len(batteries) > 0
|
|
||||||
if !systemHasBattery {
|
if !systemHasBattery {
|
||||||
slog.Debug("No battery found", "err", err)
|
slog.Debug("No battery found", "err", err)
|
||||||
}
|
}
|
||||||
return systemHasBattery
|
return systemHasBattery
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
// 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 !HasReadableBattery() {
|
||||||
@@ -53,21 +52,26 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
totalCharge := float64(0)
|
totalCharge := float64(0)
|
||||||
errs, partialErrs := err.(battery.Errors)
|
errs, partialErrs := err.(battery.Errors)
|
||||||
|
|
||||||
|
batteryState = math.MaxUint8
|
||||||
|
|
||||||
for i, bat := range batteries {
|
for i, bat := range batteries {
|
||||||
if partialErrs && errs[i] != nil {
|
if partialErrs && errs[i] != nil {
|
||||||
// if there were some errors, like missing data, skip it
|
// if there were some errors, like missing data, skip it
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if bat.Full == 0 {
|
if bat == nil || bat.Full == 0 {
|
||||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
// 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.
|
// we can't guarantee that, so don't skip based on charge.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalCapacity += bat.Full
|
totalCapacity += bat.Full
|
||||||
totalCharge += bat.Current
|
totalCharge += bat.Current
|
||||||
|
if bat.State.Raw >= 0 {
|
||||||
|
batteryState = uint8(bat.State.Raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if totalCapacity == 0 {
|
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||||
// for macs there's sometimes a ghost battery with 0 capacity
|
// for macs there's sometimes a ghost battery with 0 capacity
|
||||||
// https://github.com/distatus/battery/issues/34
|
// https://github.com/distatus/battery/issues/34
|
||||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||||
@@ -76,6 +80,5 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||||
batteryState = uint8(batteries[0].State.Raw)
|
|
||||||
return batteryPercent, batteryState, nil
|
return batteryPercent, batteryState, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"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/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"
|
||||||
@@ -276,6 +277,8 @@ func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
|||||||
response.String = &v
|
response.String = &v
|
||||||
case map[string]smart.SmartData:
|
case map[string]smart.SmartData:
|
||||||
response.SmartData = v
|
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,10 +4,12 @@ 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.
|
||||||
@@ -15,23 +17,92 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCpuPercent calculates the CPU usage percentage using cached previous measurements.
|
// CpuMetrics contains detailed CPU usage breakdown
|
||||||
// It uses the specified cache time interval to determine the time window for calculation.
|
type CpuMetrics struct {
|
||||||
// Returns the CPU usage percentage (0-100) and any error encountered.
|
Total float64
|
||||||
func getCpuPercent(cacheTimeMs uint16) (float64, error) {
|
User float64
|
||||||
|
System float64
|
||||||
|
Iowait float64
|
||||||
|
Steal float64
|
||||||
|
Idle float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
||||||
|
// It returns percentages for total, user, system, iowait, and steal time.
|
||||||
|
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
||||||
times, err := cpu.Times(false)
|
times, err := cpu.Times(false)
|
||||||
if err != nil || len(times) == 0 {
|
if err != nil || len(times) == 0 {
|
||||||
return 0, err
|
return CpuMetrics{}, 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 delta, nil
|
return metrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampPercent ensures the percentage is between 0 and 100
|
||||||
|
func clampPercent(value float64) float64 {
|
||||||
|
return math.Min(100, math.Max(0, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPerCoreCpuUsage calculates per-core CPU busy usage as integer percentages (0-100).
|
||||||
|
// It uses cached previous measurements for the provided cache interval.
|
||||||
|
func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
|
||||||
|
perCoreTimes, err := cpu.Times(true)
|
||||||
|
if err != nil || len(perCoreTimes) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize cache if needed
|
||||||
|
if _, ok := lastPerCoreCpuTimes[cacheTimeMs]; !ok {
|
||||||
|
lastPerCoreCpuTimes[cacheTimeMs] = lastPerCoreCpuTimes[60000]
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTimes := lastPerCoreCpuTimes[cacheTimeMs]
|
||||||
|
|
||||||
|
// Limit to the number of cores available in both samples
|
||||||
|
length := len(perCoreTimes)
|
||||||
|
if len(lastTimes) < length {
|
||||||
|
length = len(lastTimes)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage := make([]uint8, length)
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
t1 := lastTimes[i]
|
||||||
|
t2 := perCoreTimes[i]
|
||||||
|
usage[i] = uint8(math.Round(calculateBusy(t1, t2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPerCoreCpuTimes[cacheTimeMs] = perCoreTimes
|
||||||
|
return usage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateBusy calculates the CPU busy percentage between two time points.
|
// calculateBusy calculates the CPU busy percentage between two time points.
|
||||||
@@ -41,13 +112,10 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|||||||
t1All, t1Busy := getAllBusy(t1)
|
t1All, t1Busy := getAllBusy(t1)
|
||||||
t2All, t2Busy := getAllBusy(t2)
|
t2All, t2Busy := getAllBusy(t2)
|
||||||
|
|
||||||
if t2Busy <= t1Busy {
|
if t2All <= t1All || t2Busy <= t1Busy {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if t2All <= t1All {
|
return clampPercent((t2Busy - t1Busy) / (t2All - t1All) * 100)
|
||||||
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.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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 {
|
||||||
@@ -38,6 +39,13 @@ 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"},
|
||||||
// )
|
// )
|
||||||
@@ -52,7 +60,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 runtime.GOOS == "windows" {
|
if isWindows {
|
||||||
key = device
|
key = device
|
||||||
} else {
|
} else {
|
||||||
key = filepath.Base(device)
|
key = filepath.Base(device)
|
||||||
@@ -87,6 +95,9 @@ 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 {
|
||||||
@@ -130,7 +141,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 == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == rootMountPoint || (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)
|
||||||
@@ -166,8 +177,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", "/", "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
@@ -214,8 +225,19 @@ 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)
|
||||||
@@ -233,6 +255,11 @@ 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
|
||||||
@@ -304,3 +331,32 @@ 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,6 +7,7 @@ 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"
|
||||||
@@ -233,3 +234,86 @@ 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,6 +25,10 @@ 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
|
||||||
@@ -32,6 +38,12 @@ const (
|
|||||||
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
maxMemoryUsage uint64 = 100 * 1024 * 1024 * 1024 * 1024
|
||||||
// Number of log lines to request when fetching container logs
|
// Number of log lines to request when fetching container logs
|
||||||
dockerLogsTail = 200
|
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 {
|
||||||
@@ -47,6 +59,7 @@ 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
|
||||||
|
|
||||||
// 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
|
||||||
@@ -88,6 +101,19 @@ 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")
|
||||||
@@ -115,6 +141,13 @@ 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
|
||||||
@@ -497,6 +530,19 @@ func newDockerManager(a *Agent) *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,
|
||||||
@@ -506,6 +552,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
apiStats: &container.ApiStats{},
|
apiStats: &container.ApiStats{},
|
||||||
|
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),
|
||||||
@@ -650,13 +697,18 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String(), nil
|
// 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 {
|
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
||||||
const headerSize = 8
|
const headerSize = 8
|
||||||
var header [headerSize]byte
|
var header [headerSize]byte
|
||||||
buf := make([]byte, 0, dockerLogsTail*200)
|
totalBytesRead := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
if _, err := io.ReadFull(reader, header[:]); err != nil {
|
||||||
@@ -671,30 +723,26 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
buf = allocateBuffer(buf, int(frameLen))
|
// Prevent memory exhaustion from excessively large frames
|
||||||
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
|
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) {
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
if len(buf) > 0 {
|
|
||||||
builder.Write(buf[:min(int(frameLen), len(buf))])
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
builder.Write(buf[:frameLen])
|
totalBytesRead += int(n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allocateBuffer(current []byte, needed int) []byte {
|
|
||||||
if cap(current) >= needed {
|
|
||||||
return current[:needed]
|
|
||||||
}
|
|
||||||
return make([]byte, needed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -911,6 +913,8 @@ func TestConstantsAndUtilityFunctions(t *testing.T) {
|
|||||||
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))
|
||||||
@@ -921,3 +925,290 @@ 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,7 +49,12 @@ 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) {
|
||||||
cmd := exec.Command(intelGpuStatsCmd, "-s", intelGpuStatsInterval, "-l")
|
// Build command arguments, optionally selecting a device via -d
|
||||||
|
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||||
|
if dev, ok := GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||||
|
args = append(args, "-d", dev)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(intelGpuStatsCmd, args...)
|
||||||
// Avoid blocking if intel_gpu_top writes to stderr
|
// 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()
|
||||||
@@ -129,7 +134,9 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
|
|||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
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 { return r >= '0' && r <= '9' })
|
key := strings.TrimRightFunc(col, func(r rune) bool {
|
||||||
|
return (r >= '0' && r <= '9') || r == '/'
|
||||||
|
})
|
||||||
var friendly string
|
var friendly string
|
||||||
switch key {
|
switch key {
|
||||||
case "RCS":
|
case "RCS":
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1437,6 +1439,15 @@ 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",
|
||||||
@@ -1624,3 +1635,42 @@ func TestParseIntelData(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntelCollectorDeviceEnv(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
|
||||||
|
// Prepare a file to capture args
|
||||||
|
argsFile := filepath.Join(dir, "args.txt")
|
||||||
|
|
||||||
|
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
|
||||||
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
|
echo "$@" > %s
|
||||||
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||||
|
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
||||||
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
||||||
|
`, argsFile)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set device selector via prefixed env var
|
||||||
|
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
||||||
|
|
||||||
|
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||||
|
if err := gm.collectIntelStats(); err != nil {
|
||||||
|
t.Fatalf("collectIntelStats error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that -d sriov was passed
|
||||||
|
data, err := os.ReadFile(argsFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed reading args file: %v", err)
|
||||||
|
}
|
||||||
|
argsStr := strings.TrimSpace(string(data))
|
||||||
|
require.Contains(t, argsStr, "-d sriov")
|
||||||
|
require.Contains(t, argsStr, "-s ")
|
||||||
|
require.Contains(t, argsStr, "-l")
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
|
||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -168,9 +169,37 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
// return empty map to indicate no data
|
// return empty map to indicate no data
|
||||||
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
return hctx.SendResponse(map[string]smart.SmartData{}, hctx.RequestID)
|
||||||
}
|
}
|
||||||
if err := hctx.Agent.smartManager.Refresh(); err != nil {
|
if err := hctx.Agent.smartManager.Refresh(false); err != nil {
|
||||||
slog.Debug("smart refresh failed", "err", err)
|
slog.Debug("smart refresh failed", "err", err)
|
||||||
}
|
}
|
||||||
data := hctx.Agent.smartManager.GetCurrentData()
|
data := hctx.Agent.smartManager.GetCurrentData()
|
||||||
return hctx.SendResponse(data, hctx.RequestID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ 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,6 +172,10 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
response.SystemData = v
|
response.SystemData = v
|
||||||
case string:
|
case string:
|
||||||
response.String = &v
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
698
agent/smart.go
698
agent/smart.go
@@ -1,10 +1,18 @@
|
|||||||
|
//go:generate -command fetchsmartctl go run ./tools/fetchsmartctl
|
||||||
|
//go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,9 +24,12 @@ import (
|
|||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
|
lastScanTime time.Time
|
||||||
|
binPath string
|
||||||
|
excludedDevices map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -35,18 +46,23 @@ type DeviceInfo struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
InfoName string `json:"info_name"`
|
InfoName string `json:"info_name"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
|
// typeVerified reports whether we have already parsed SMART data for this device
|
||||||
|
// with the stored parserType. When true we can skip re-running the detection logic.
|
||||||
|
typeVerified bool
|
||||||
|
// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
|
||||||
|
parserType string
|
||||||
}
|
}
|
||||||
|
|
||||||
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data
|
||||||
|
|
||||||
// Refresh updates SMART data for all known devices on demand.
|
// Refresh updates SMART data for all known devices
|
||||||
func (sm *SmartManager) Refresh() error {
|
func (sm *SmartManager) Refresh(forceScan bool) error {
|
||||||
sm.refreshMutex.Lock()
|
sm.refreshMutex.Lock()
|
||||||
defer sm.refreshMutex.Unlock()
|
defer sm.refreshMutex.Unlock()
|
||||||
|
|
||||||
scanErr := sm.ScanDevices()
|
scanErr := sm.ScanDevices(false)
|
||||||
if scanErr != nil {
|
if scanErr != nil {
|
||||||
slog.Warn("smartctl scan failed", "err", scanErr)
|
slog.Debug("smartctl scan failed", "err", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
devices := sm.devicesSnapshot()
|
devices := sm.devicesSnapshot()
|
||||||
@@ -56,7 +72,7 @@ func (sm *SmartManager) Refresh() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := sm.CollectSmart(deviceInfo); err != nil {
|
if err := sm.CollectSmart(deviceInfo); err != nil {
|
||||||
slog.Info("smartctl collect failed for device, skipping", "device", deviceInfo.Name, "err", err)
|
slog.Debug("smartctl collect failed", "device", deviceInfo.Name, "err", err)
|
||||||
collectErr = err
|
collectErr = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,77 +142,356 @@ func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
|
|||||||
// Scan devices using `smartctl --scan -j`
|
// Scan devices using `smartctl --scan -j`
|
||||||
// If scan fails, return error
|
// If scan fails, return error
|
||||||
// If scan succeeds, parse the output and update the SmartDevices slice
|
// If scan succeeds, parse the output and update the SmartDevices slice
|
||||||
func (sm *SmartManager) ScanDevices() error {
|
func (sm *SmartManager) ScanDevices(force bool) error {
|
||||||
|
if !force && time.Since(sm.lastScanTime) < 30*time.Minute {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sm.lastScanTime = time.Now()
|
||||||
|
currentDevices := sm.devicesSnapshot()
|
||||||
|
|
||||||
|
var configuredDevices []*DeviceInfo
|
||||||
|
if configuredRaw, ok := GetEnv("SMART_DEVICES"); ok {
|
||||||
|
slog.Info("SMART_DEVICES", "value", configuredRaw)
|
||||||
|
config := strings.TrimSpace(configuredRaw)
|
||||||
|
if config == "" {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedDevices, err := sm.parseConfiguredDevices(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configuredDevices = parsedDevices
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
|
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|
||||||
|
var (
|
||||||
|
scanErr error
|
||||||
|
scannedDevices []*DeviceInfo
|
||||||
|
hasValidScan bool
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
scanErr = err
|
||||||
|
} else {
|
||||||
|
scannedDevices, hasValidScan = sm.parseScan(output)
|
||||||
|
if !hasValidScan {
|
||||||
|
scanErr = errNoValidSmartData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasValidData := sm.parseScan(output)
|
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||||
if !hasValidData {
|
finalDevices = sm.filterExcludedDevices(finalDevices)
|
||||||
|
sm.updateSmartDevices(finalDevices)
|
||||||
|
|
||||||
|
if len(finalDevices) == 0 {
|
||||||
|
if scanErr != nil {
|
||||||
|
slog.Debug("smartctl scan failed", "err", scanErr)
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
|
||||||
|
entries := strings.Split(config, ",")
|
||||||
|
devices := make([]*DeviceInfo, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(entry, ":", 2)
|
||||||
|
|
||||||
|
name := strings.TrimSpace(parts[0])
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("invalid SMART_DEVICES entry %q", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
devType := ""
|
||||||
|
if len(parts) == 2 {
|
||||||
|
devType = strings.ToLower(strings.TrimSpace(parts[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
devices = append(devices, &DeviceInfo{
|
||||||
|
Name: name,
|
||||||
|
Type: devType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return nil, errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) refreshExcludedDevices() {
|
||||||
|
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||||
|
sm.excludedDevices = make(map[string]struct{})
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||||
|
device := strings.TrimSpace(entry)
|
||||||
|
if device == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sm.excludedDevices[device] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
|
||||||
|
_, exists := sm.excludedDevices[deviceName]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
|
||||||
|
if devices == nil {
|
||||||
|
return []*DeviceInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := sm.excludedDevices
|
||||||
|
if len(excluded) == 0 {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*DeviceInfo, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
if device == nil || device.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, skip := excluded[device.Name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, device)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
|
// when the reported device type is ambiguous or missing.
|
||||||
|
func detectSmartOutputType(output []byte) string {
|
||||||
|
var hints struct {
|
||||||
|
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
|
||||||
|
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
|
||||||
|
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &hints); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
|
||||||
|
return "nvme"
|
||||||
|
case hasJSONValue(hints.AtaSmartAttributes):
|
||||||
|
return "sat"
|
||||||
|
case hasJSONValue(hints.ScsiErrorCounterLog):
|
||||||
|
return "scsi"
|
||||||
|
default:
|
||||||
|
return "sat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasJSONValue reports whether a JSON payload contains a concrete value. The
|
||||||
|
// smartctl output often emits "null" for sections that do not apply, so we
|
||||||
|
// only treat non-null content as a hint.
|
||||||
|
func hasJSONValue(raw json.RawMessage) bool {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(string(raw))
|
||||||
|
return trimmed != "" && trimmed != "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeParserType(value string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "nvme", "sntasmedia", "sntrealtek":
|
||||||
|
return "nvme"
|
||||||
|
case "sat", "ata":
|
||||||
|
return "sat"
|
||||||
|
case "scsi":
|
||||||
|
return "scsi"
|
||||||
|
default:
|
||||||
|
return strings.ToLower(strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSmartOutput attempts each SMART parser, optionally detecting the type when
|
||||||
|
// it is not provided, and updates the device info when a parser succeeds.
|
||||||
|
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
|
||||||
|
parsers := []struct {
|
||||||
|
Type string
|
||||||
|
Parse func([]byte) (bool, int)
|
||||||
|
}{
|
||||||
|
{Type: "nvme", Parse: sm.parseSmartForNvme},
|
||||||
|
{Type: "sat", Parse: sm.parseSmartForSata},
|
||||||
|
{Type: "scsi", Parse: sm.parseSmartForScsi},
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceType := normalizeParserType(deviceInfo.parserType)
|
||||||
|
if deviceType == "" {
|
||||||
|
deviceType = normalizeParserType(deviceInfo.Type)
|
||||||
|
}
|
||||||
|
if deviceInfo.parserType == "" {
|
||||||
|
switch deviceType {
|
||||||
|
case "nvme", "sat", "scsi":
|
||||||
|
deviceInfo.parserType = deviceType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run the type detection when we do not yet know which parser works
|
||||||
|
// or the previous attempt failed.
|
||||||
|
needsDetection := deviceType == "" || !deviceInfo.typeVerified
|
||||||
|
if needsDetection {
|
||||||
|
structureType := detectSmartOutputType(output)
|
||||||
|
if deviceType != structureType {
|
||||||
|
deviceType = structureType
|
||||||
|
deviceInfo.parserType = structureType
|
||||||
|
deviceInfo.typeVerified = false
|
||||||
|
}
|
||||||
|
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, structureType) {
|
||||||
|
deviceInfo.Type = structureType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the most likely parser first, but keep the remaining parsers in reserve
|
||||||
|
// so an incorrect hint never leaves the device unparsed.
|
||||||
|
selectedParsers := make([]struct {
|
||||||
|
Type string
|
||||||
|
Parse func([]byte) (bool, int)
|
||||||
|
}, 0, len(parsers))
|
||||||
|
if deviceType != "" {
|
||||||
|
for _, parser := range parsers {
|
||||||
|
if parser.Type == deviceType {
|
||||||
|
selectedParsers = append(selectedParsers, parser)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, parser := range parsers {
|
||||||
|
alreadySelected := false
|
||||||
|
for _, selected := range selectedParsers {
|
||||||
|
if selected.Type == parser.Type {
|
||||||
|
alreadySelected = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if alreadySelected {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selectedParsers = append(selectedParsers, parser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the selected parsers in order until we find one that succeeds.
|
||||||
|
for _, parser := range selectedParsers {
|
||||||
|
hasData, _ := parser.Parse(output)
|
||||||
|
if hasData {
|
||||||
|
deviceInfo.parserType = parser.Type
|
||||||
|
if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, parser.Type) {
|
||||||
|
deviceInfo.Type = parser.Type
|
||||||
|
}
|
||||||
|
// Remember that this parser is valid so future refreshes can bypass
|
||||||
|
// detection entirely.
|
||||||
|
deviceInfo.typeVerified = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave verification false so the next pass will attempt detection again.
|
||||||
|
deviceInfo.typeVerified = false
|
||||||
|
slog.Debug("parsing failed", "device", deviceInfo.Name)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// CollectSmart collects SMART data for a device
|
// CollectSmart collects SMART data for a device
|
||||||
// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX`
|
// Collect data using `smartctl -d <type> -aj /dev/<device>` when device type is known
|
||||||
// Always attempts to parse output even if command fails, as some data may still be available
|
// Always attempts to parse output even if command fails, as some data may still be available
|
||||||
// If collect fails, return error
|
// If collect fails, return error
|
||||||
// If collect succeeds, parse the output and update the SmartDataMap
|
// If collect succeeds, parse the output and update the SmartDataMap
|
||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
// for initial data collection when no cached data exists
|
// for initial data collection when no cached data exists
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
// Check if we have any existing data for this device
|
||||||
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try with -n standby first if we have existing data
|
// Try with -n standby first if we have existing data
|
||||||
cmd := exec.CommandContext(ctx, "smartctl", "-aj", "-n", "standby", deviceInfo.Name)
|
args := sm.smartctlArgs(deviceInfo, true)
|
||||||
|
cmd := exec.CommandContext(ctx, sm.binPath, args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
// Check if device is in standby (exit status 2)
|
// Check if device is in standby (exit status 2)
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 2 {
|
||||||
if hasExistingData {
|
if hasExistingData {
|
||||||
// Device is in standby and we have cached data, keep using cache
|
// Device is in standby and we have cached data, keep using cache
|
||||||
slog.Debug("device in standby mode, using cached data", "device", deviceInfo.Name)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// No cached data, need to collect initial data by bypassing standby
|
// No cached data, need to collect initial data by bypassing standby
|
||||||
slog.Debug("device in standby but no cached data, collecting initial data", "device", deviceInfo.Name)
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel2()
|
defer cancel2()
|
||||||
cmd = exec.CommandContext(ctx2, "smartctl", "-aj", deviceInfo.Name)
|
args = sm.smartctlArgs(deviceInfo, false)
|
||||||
|
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
hasValidData := false
|
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
||||||
|
|
||||||
switch deviceInfo.Type {
|
|
||||||
case "scsi", "sat", "ata":
|
|
||||||
// parse SATA/SCSI/ATA devices
|
|
||||||
hasValidData, _ = sm.parseSmartForSata(output)
|
|
||||||
case "nvme":
|
|
||||||
// parse nvme devices
|
|
||||||
hasValidData, _ = sm.parseSmartForNvme(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasValidData {
|
if !hasValidData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// smartctlArgs returns the arguments for the smartctl command
|
||||||
|
// based on the device type and whether to include standby mode
|
||||||
|
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
|
||||||
|
args := make([]string, 0, 7)
|
||||||
|
|
||||||
|
if deviceInfo != nil {
|
||||||
|
deviceType := strings.ToLower(deviceInfo.Type)
|
||||||
|
// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
|
||||||
|
if deviceType != "" && deviceType != "scsi" && deviceType != "ata" {
|
||||||
|
args = append(args, "-d", deviceInfo.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "-a", "--json=c")
|
||||||
|
|
||||||
|
if includeStandby {
|
||||||
|
args = append(args, "-n", "standby")
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceInfo != nil {
|
||||||
|
args = append(args, deviceInfo.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
// hasDataForDevice checks if we have cached SMART data for a specific device
|
// hasDataForDevice checks if we have cached SMART data for a specific device
|
||||||
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
||||||
sm.Lock()
|
sm.Lock()
|
||||||
@@ -211,43 +506,194 @@ func (sm *SmartManager) hasDataForDevice(deviceName string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice
|
// parseScan parses the output of smartctl --scan -j and returns the discovered devices.
|
||||||
func (sm *SmartManager) parseScan(output []byte) bool {
|
func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) {
|
||||||
sm.Lock()
|
|
||||||
defer sm.Unlock()
|
|
||||||
|
|
||||||
sm.SmartDevices = make([]*DeviceInfo, 0)
|
|
||||||
scan := &scanOutput{}
|
scan := &scanOutput{}
|
||||||
|
|
||||||
if err := json.Unmarshal(output, scan); err != nil {
|
if err := json.Unmarshal(output, scan); err != nil {
|
||||||
slog.Warn("Failed to parse smartctl scan JSON", "err", err)
|
return nil, false
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(scan.Devices) == 0 {
|
if len(scan.Devices) == 0 {
|
||||||
return false
|
slog.Debug("no devices found in smartctl scan")
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
scannedDeviceNameMap := make(map[string]bool, len(scan.Devices))
|
devices := make([]*DeviceInfo, 0, len(scan.Devices))
|
||||||
|
|
||||||
for _, device := range scan.Devices {
|
for _, device := range scan.Devices {
|
||||||
deviceInfo := &DeviceInfo{
|
slog.Debug("smartctl scan", "name", device.Name, "type", device.Type, "protocol", device.Protocol)
|
||||||
|
devices = append(devices, &DeviceInfo{
|
||||||
Name: device.Name,
|
Name: device.Name,
|
||||||
Type: device.Type,
|
Type: device.Type,
|
||||||
InfoName: device.InfoName,
|
InfoName: device.InfoName,
|
||||||
Protocol: device.Protocol,
|
Protocol: device.Protocol,
|
||||||
}
|
})
|
||||||
sm.SmartDevices = append(sm.SmartDevices, deviceInfo)
|
|
||||||
scannedDeviceNameMap[device.Name] = true
|
|
||||||
}
|
|
||||||
// remove devices that are not in the scan
|
|
||||||
for key := range sm.SmartDataMap {
|
|
||||||
if _, ok := scannedDeviceNameMap[key]; !ok {
|
|
||||||
delete(sm.SmartDataMap, key)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return devices, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeDeviceLists combines scanned and configured SMART devices, preferring
|
||||||
|
// configured SMART_DEVICES when both sources reference the same device.
|
||||||
|
func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo {
|
||||||
|
if len(scanned) == 0 && len(configured) == 0 {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
// preserveVerifiedType copies the verified type/parser metadata from an existing
|
||||||
|
// device record so that subsequent scans/config updates never downgrade a
|
||||||
|
// previously verified device.
|
||||||
|
preserveVerifiedType := func(target, prev *DeviceInfo) {
|
||||||
|
if prev == nil || !prev.typeVerified {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.Type = prev.Type
|
||||||
|
target.typeVerified = true
|
||||||
|
target.parserType = prev.parserType
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIndex := make(map[string]*DeviceInfo, len(existing))
|
||||||
|
for _, dev := range existing {
|
||||||
|
if dev == nil || dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existingIndex[dev.Name] = dev
|
||||||
|
}
|
||||||
|
|
||||||
|
finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
|
||||||
|
deviceIndex := make(map[string]*DeviceInfo, len(scanned)+len(configured))
|
||||||
|
|
||||||
|
// Start with the newly scanned devices so we always surface fresh metadata,
|
||||||
|
// but ensure we retain any previously verified parser assignment.
|
||||||
|
for _, dev := range scanned {
|
||||||
|
if dev == nil || dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work on a copy so we can safely adjust metadata without mutating the
|
||||||
|
// input slices that may be reused elsewhere.
|
||||||
|
copyDev := *dev
|
||||||
|
if prev := existingIndex[copyDev.Name]; prev != nil {
|
||||||
|
preserveVerifiedType(©Dev, prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalDevices = append(finalDevices, ©Dev)
|
||||||
|
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge configured devices on top so users can override scan results (except
|
||||||
|
// for verified type information).
|
||||||
|
for _, dev := range configured {
|
||||||
|
if dev == nil || dev.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingDev, ok := deviceIndex[dev.Name]; ok {
|
||||||
|
// Only update the type if it has not been verified yet; otherwise we
|
||||||
|
// keep the existing verified metadata intact.
|
||||||
|
if dev.Type != "" && !existingDev.typeVerified {
|
||||||
|
newType := strings.TrimSpace(dev.Type)
|
||||||
|
existingDev.Type = newType
|
||||||
|
existingDev.typeVerified = false
|
||||||
|
existingDev.parserType = normalizeParserType(newType)
|
||||||
|
}
|
||||||
|
if dev.InfoName != "" {
|
||||||
|
existingDev.InfoName = dev.InfoName
|
||||||
|
}
|
||||||
|
if dev.Protocol != "" {
|
||||||
|
existingDev.Protocol = dev.Protocol
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDev := *dev
|
||||||
|
if prev := existingIndex[copyDev.Name]; prev != nil {
|
||||||
|
preserveVerifiedType(©Dev, prev)
|
||||||
|
} else if copyDev.Type != "" {
|
||||||
|
copyDev.parserType = normalizeParserType(copyDev.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalDevices = append(finalDevices, ©Dev)
|
||||||
|
deviceIndex[copyDev.Name] = finalDevices[len(finalDevices)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSmartDevices replaces the cached device list and prunes SMART data
|
||||||
|
// entries whose backing device no longer exists.
|
||||||
|
func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
sm.SmartDevices = devices
|
||||||
|
|
||||||
|
if len(sm.SmartDataMap) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validNames := make(map[string]struct{}, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
if device == nil || device.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validNames[device.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, data := range sm.SmartDataMap {
|
||||||
|
if data == nil {
|
||||||
|
delete(sm.SmartDataMap, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := validNames[data.DiskName]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(sm.SmartDataMap, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
|
||||||
|
func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool {
|
||||||
|
vendorUpper := strings.ToUpper(data.ScsiVendor)
|
||||||
|
productUpper := strings.ToUpper(data.ScsiProduct)
|
||||||
|
modelUpper := strings.ToUpper(data.ModelName)
|
||||||
|
|
||||||
|
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out
|
||||||
|
func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool {
|
||||||
|
modelUpper := strings.ToUpper(data.ModelName)
|
||||||
|
|
||||||
|
return sm.isVirtualDeviceFromStrings(modelUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out
|
||||||
|
func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool {
|
||||||
|
vendorUpper := strings.ToUpper(data.ScsiVendor)
|
||||||
|
productUpper := strings.ToUpper(data.ScsiProduct)
|
||||||
|
modelUpper := strings.ToUpper(data.ScsiModelName)
|
||||||
|
|
||||||
|
return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device
|
||||||
|
func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
|
||||||
|
for _, field := range fields {
|
||||||
|
fieldUpper := strings.ToUpper(field)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target
|
||||||
|
strings.Contains(fieldUpper, "VIRTUAL"),
|
||||||
|
strings.Contains(fieldUpper, "QEMU"),
|
||||||
|
strings.Contains(fieldUpper, "VBOX"),
|
||||||
|
strings.Contains(fieldUpper, "VMWARE"),
|
||||||
|
strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap
|
||||||
@@ -260,14 +706,19 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if data.SerialNumber == "" {
|
if data.SerialNumber == "" {
|
||||||
slog.Warn("device has no serial number, skipping", "device", data.Device.Name)
|
slog.Debug("no serial number", "device", data.Device.Name)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
||||||
|
if sm.isVirtualDevice(&data) {
|
||||||
|
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
|
||||||
return false, data.Smartctl.ExitStatus
|
return false, data.Smartctl.ExitStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
sm.Lock()
|
sm.Lock()
|
||||||
defer sm.Unlock()
|
defer sm.Unlock()
|
||||||
|
|
||||||
// get device name (e.g. /dev/sda)
|
|
||||||
keyName := data.SerialNumber
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
// if device does not exist in SmartDataMap, initialize it
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
@@ -290,13 +741,17 @@ func (sm *SmartManager) parseSmartForSata(output []byte) (bool, int) {
|
|||||||
// update SmartAttributes
|
// update SmartAttributes
|
||||||
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||||
for _, attr := range data.AtaSmartAttributes.Table {
|
for _, attr := range data.AtaSmartAttributes.Table {
|
||||||
|
rawValue := uint64(attr.Raw.Value)
|
||||||
|
if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
|
||||||
|
rawValue = parsed
|
||||||
|
}
|
||||||
smartAttr := &smart.SmartAttribute{
|
smartAttr := &smart.SmartAttribute{
|
||||||
ID: attr.ID,
|
ID: attr.ID,
|
||||||
Name: attr.Name,
|
Name: attr.Name,
|
||||||
Value: attr.Value,
|
Value: attr.Value,
|
||||||
Worst: attr.Worst,
|
Worst: attr.Worst,
|
||||||
Threshold: attr.Thresh,
|
Threshold: attr.Thresh,
|
||||||
RawValue: attr.Raw.Value,
|
RawValue: rawValue,
|
||||||
RawString: attr.Raw.String,
|
RawString: attr.Raw.String,
|
||||||
WhenFailed: attr.WhenFailed,
|
WhenFailed: attr.WhenFailed,
|
||||||
}
|
}
|
||||||
@@ -317,6 +772,92 @@ func getSmartStatus(temperature uint8, passed bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) parseSmartForScsi(output []byte) (bool, int) {
|
||||||
|
var data smart.SmartInfoForScsi
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.SerialNumber == "" {
|
||||||
|
slog.Debug("no serial number", "device", data.Device.Name)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
||||||
|
if sm.isVirtualDeviceScsi(&data) {
|
||||||
|
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.Lock()
|
||||||
|
defer sm.Unlock()
|
||||||
|
|
||||||
|
keyName := data.SerialNumber
|
||||||
|
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||||
|
sm.SmartDataMap[keyName] = &smart.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
smartData := sm.SmartDataMap[keyName]
|
||||||
|
smartData.ModelName = data.ScsiModelName
|
||||||
|
smartData.SerialNumber = data.SerialNumber
|
||||||
|
smartData.FirmwareVersion = data.ScsiRevision
|
||||||
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
smartData.Temperature = data.Temperature.Current
|
||||||
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
|
smartData.DiskName = data.Device.Name
|
||||||
|
smartData.DiskType = data.Device.Type
|
||||||
|
|
||||||
|
attributes := make([]*smart.SmartAttribute, 0, 10)
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime})
|
||||||
|
|
||||||
|
readStats := data.ScsiErrorCounterLog.Read
|
||||||
|
writeStats := data.ScsiErrorCounterLog.Write
|
||||||
|
verifyStats := data.ScsiErrorCounterLog.Verify
|
||||||
|
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations})
|
||||||
|
if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 {
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)})
|
||||||
|
}
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations})
|
||||||
|
if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 {
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)})
|
||||||
|
}
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors})
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations})
|
||||||
|
if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 {
|
||||||
|
attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)})
|
||||||
|
}
|
||||||
|
|
||||||
|
smartData.Attributes = attributes
|
||||||
|
sm.SmartDataMap[keyName] = smartData
|
||||||
|
|
||||||
|
return true, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScsiGigabytesProcessed(value string) int64 {
|
||||||
|
if value == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
normalized := strings.ReplaceAll(value, ",", "")
|
||||||
|
parsed, err := strconv.ParseInt(normalized, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
// Returns hasValidData and exitStatus
|
// Returns hasValidData and exitStatus
|
||||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||||
@@ -327,14 +868,19 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if data.SerialNumber == "" {
|
if data.SerialNumber == "" {
|
||||||
slog.Warn("device has no serial number, skipping", "device", data.Device.Name)
|
slog.Debug("no serial number", "device", data.Device.Name)
|
||||||
|
return false, data.Smartctl.ExitStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
||||||
|
if sm.isVirtualDeviceNvme(data) {
|
||||||
|
slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
|
||||||
return false, data.Smartctl.ExitStatus
|
return false, data.Smartctl.ExitStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
sm.Lock()
|
sm.Lock()
|
||||||
defer sm.Unlock()
|
defer sm.Unlock()
|
||||||
|
|
||||||
// get device name (e.g. /dev/nvme0)
|
|
||||||
keyName := data.SerialNumber
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
// if device does not exist in SmartDataMap, initialize it
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
@@ -382,11 +928,33 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// detectSmartctl checks if smartctl is installed, returns an error if not
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
func (sm *SmartManager) detectSmartctl() error {
|
func (sm *SmartManager) detectSmartctl() (string, error) {
|
||||||
if _, err := exec.LookPath("smartctl"); err == nil {
|
isWindows := runtime.GOOS == "windows"
|
||||||
return nil
|
|
||||||
|
// Load embedded smartctl.exe for Windows amd64 builds.
|
||||||
|
if isWindows && runtime.GOARCH == "amd64" {
|
||||||
|
if path, err := ensureEmbeddedSmartctl(); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no smartctl found - install smartctl")
|
|
||||||
|
if path, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
locations := []string{}
|
||||||
|
if isWindows {
|
||||||
|
locations = append(locations,
|
||||||
|
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
locations = append(locations, "/opt/homebrew/bin/smartctl")
|
||||||
|
}
|
||||||
|
for _, location := range locations {
|
||||||
|
if _, err := os.Stat(location); err == nil {
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("smartctl not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSmartManager creates and initializes a new SmartManager
|
// NewSmartManager creates and initializes a new SmartManager
|
||||||
@@ -394,9 +962,13 @@ func NewSmartManager() (*SmartManager, error) {
|
|||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
if err := sm.detectSmartctl(); err != nil {
|
sm.refreshExcludedDevices()
|
||||||
|
path, err := sm.detectSmartctl()
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug(err.Error())
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
slog.Debug("smartctl", "path", path)
|
||||||
|
sm.binPath = path
|
||||||
return sm, nil
|
return sm, nil
|
||||||
}
|
}
|
||||||
|
|||||||
9
agent/smart_nonwindows.go
Normal file
9
agent/smart_nonwindows.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func ensureEmbeddedSmartctl() (string, error) {
|
||||||
|
return "", errors.ErrUnsupported
|
||||||
|
}
|
||||||
782
agent/smart_test.go
Normal file
782
agent/smart_test.go
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
//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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
40
agent/smart_windows.go
Normal file
40
agent/smart_windows.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed smartmontools/smartctl.exe
|
||||||
|
var embeddedSmartctl []byte
|
||||||
|
|
||||||
|
var (
|
||||||
|
smartctlOnce sync.Once
|
||||||
|
smartctlPath string
|
||||||
|
smartctlErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureEmbeddedSmartctl() (string, error) {
|
||||||
|
smartctlOnce.Do(func() {
|
||||||
|
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
|
||||||
|
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||||
|
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(destDir, "smartctl.exe")
|
||||||
|
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
|
||||||
|
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
smartctlPath = destPath
|
||||||
|
})
|
||||||
|
|
||||||
|
return smartctlPath, smartctlErr
|
||||||
|
}
|
||||||
@@ -83,12 +83,24 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
systemStats.Battery[1] = batteryState
|
systemStats.Battery[1] = batteryState
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu percent
|
// cpu metrics
|
||||||
cpuPercent, err := getCpuPercent(cacheTimeMs)
|
cpuMetrics, err := getCpuMetrics(cacheTimeMs)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
systemStats.Cpu = twoDecimals(cpuPercent)
|
systemStats.Cpu = twoDecimals(cpuMetrics.Total)
|
||||||
|
systemStats.CpuBreakdown = []float64{
|
||||||
|
twoDecimals(cpuMetrics.User),
|
||||||
|
twoDecimals(cpuMetrics.System),
|
||||||
|
twoDecimals(cpuMetrics.Iowait),
|
||||||
|
twoDecimals(cpuMetrics.Steal),
|
||||||
|
twoDecimals(cpuMetrics.Idle),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
slog.Error("Error getting cpu percent", "err", err)
|
slog.Error("Error getting cpu metrics", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// per-core cpu usage
|
||||||
|
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
|
||||||
|
systemStats.CpuCoresUsage = perCoreUsage
|
||||||
}
|
}
|
||||||
|
|
||||||
// load average
|
// load average
|
||||||
|
|||||||
273
agent/systemd.go
Normal file
273
agent/systemd.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
38
agent/systemd_nonlinux.go
Normal file
38
agent/systemd_nonlinux.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
53
agent/systemd_nonlinux_test.go
Normal file
53
agent/systemd_nonlinux_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//go:build !linux && testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSystemdManager(t *testing.T) {
|
||||||
|
manager, err := newSystemdManager()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, manager)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdManagerGetServiceStats(t *testing.T) {
|
||||||
|
manager, err := newSystemdManager()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test with refresh = true
|
||||||
|
result := manager.getServiceStats(true)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
// Test with refresh = false
|
||||||
|
result = manager.getServiceStats(false)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdManagerGetServiceDetails(t *testing.T) {
|
||||||
|
manager, err := newSystemdManager()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
result, err := manager.getServiceDetails("any-service")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "systemd manager unavailable", err.Error())
|
||||||
|
assert.Nil(t, result)
|
||||||
|
|
||||||
|
// Test with empty service name
|
||||||
|
result, err = manager.getServiceDetails("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "systemd manager unavailable", err.Error())
|
||||||
|
assert.Nil(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdManagerFields(t *testing.T) {
|
||||||
|
manager, err := newSystemdManager()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// The non-linux manager should be a simple struct with no special fields
|
||||||
|
// We can't test private fields directly, but we can test the methods work
|
||||||
|
assert.NotNil(t, manager)
|
||||||
|
}
|
||||||
158
agent/systemd_test.go
Normal file
158
agent/systemd_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
272
agent/test-data/smart/nvme0.json
Normal file
272
agent/test-data/smart/nvme0.json
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
agent/test-data/smart/scan.json
Normal file
36
agent/test-data/smart/scan.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
125
agent/test-data/smart/scsi.json
Normal file
125
agent/test-data/smart/scsi.json
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1013
agent/test-data/smart/sda.json
Normal file
1013
agent/test-data/smart/sda.json
Normal file
File diff suppressed because it is too large
Load Diff
130
agent/tools/fetchsmartctl/main.go
Normal file
130
agent/tools/fetchsmartctl/main.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Download smartctl.exe from the given URL and save it to the given destination.
|
||||||
|
// This is used to embed smartctl.exe in the Windows build.
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
|
||||||
|
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
|
||||||
|
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
|
||||||
|
force := flag.Bool("force", false, "Force re-download even if destination exists")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *url == "" || *out == "" {
|
||||||
|
fatalf("-url and -out are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*force {
|
||||||
|
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
|
||||||
|
fmt.Println("smartctl.exe already present, skipping download")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := downloadFile(*url, *out, *sha); err != nil {
|
||||||
|
fatalf("download failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest, shaHex string) error {
|
||||||
|
// Prepare destination
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP client
|
||||||
|
client := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http get: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := dest + ".tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open tmp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
|
||||||
|
var hasher hash.Hash
|
||||||
|
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
|
||||||
|
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
|
||||||
|
switch len(cleanSha) {
|
||||||
|
case 40:
|
||||||
|
hasher = sha1.New()
|
||||||
|
case 64:
|
||||||
|
hasher = sha256.New()
|
||||||
|
default:
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mw io.Writer = f
|
||||||
|
if hasher != nil {
|
||||||
|
mw = io.MultiWriter(f, hasher)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(mw, resp.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("write tmp: %w", err)
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("close tmp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasher != nil && shaHex != "" {
|
||||||
|
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
|
||||||
|
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
|
||||||
|
if got != cleanSha {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make executable and move into place
|
||||||
|
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("chmod: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, dest); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return fmt.Errorf("rename: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("smartctl.exe downloaded to", dest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, a ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -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.14.1"
|
Version = "0.17.0"
|
||||||
// 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,27 +1,25 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.5
|
||||||
|
|
||||||
// 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.10.0
|
github.com/nicholas-fedor/shoutrrr v0.12.1
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.30.1
|
github.com/pocketbase/pocketbase v0.34.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.9
|
github.com/shirou/gopsutil/v4 v4.25.10
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.42.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
|
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,37 +31,38 @@ 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.0 // indirect
|
github.com/ebitengine/purego v0.9.1 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/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.0 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.31.0 // indirect
|
golang.org/x/image v0.33.0 // indirect
|
||||||
golang.org/x/net v0.44.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.31.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.36.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.3 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.39.0 // indirect
|
modernc.org/sqlite v1.40.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
132
go.sum
132
go.sum
@@ -9,6 +9,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
github.com/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=
|
||||||
@@ -23,16 +25,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.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.1/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.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 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=
|
||||||
@@ -49,40 +51,44 @@ 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-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
|
||||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs=
|
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk=
|
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
|
||||||
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||||
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
@@ -90,8 +96,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.30.1 h1:8lgfhH+HiSw1PyKVMq2sjtC4ZNvda2f/envTAzWMLOA=
|
github.com/pocketbase/pocketbase v0.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
|
||||||
github.com/pocketbase/pocketbase v0.30.1/go.mod h1:sUI+uekXZam5Wa0eh+DClc+HieKMCeqsHA7Ydd9vwyE=
|
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -99,8 +105,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.9 h1:JImNpf6gCVhKgZhtaAHJ0serfFGtlfIlSC08eaKdTrU=
|
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.9/go.mod h1:gxIxoC+7nQRwUl/xNhutXlD8lq+jxTgpIkEf3rADHL8=
|
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 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=
|
||||||
@@ -112,75 +118,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.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
|
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.18.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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.8/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.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/libc v1.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=
|
||||||
@@ -189,8 +197,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.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
|
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||||
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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
|
||||||
@@ -40,13 +41,19 @@ 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"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
LoadAvg [3]float64 `json:"la"`
|
||||||
|
Battery [2]uint8 `json:"bat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemAlertGPUData struct {
|
||||||
|
Usage float64 `json:"u"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
@@ -72,7 +79,6 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
"matrix": {},
|
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -99,10 +105,84 @@ 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}",
|
||||||
|
|||||||
387
internal/alerts/alerts_battery_test.go
Normal file
387
internal/alerts/alerts_battery_test.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
//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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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{
|
||||||
|
Hostname: "test-host",
|
||||||
|
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")
|
||||||
|
}
|
||||||
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
67
internal/alerts/alerts_smart.go
Normal file
67
internal/alerts/alerts_smart.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
196
internal/alerts/alerts_smart_test.go
Normal file
196
internal/alerts/alerts_smart_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
//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,19 +161,15 @@ 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)
|
||||||
|
|
||||||
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
// Get system ID for the link
|
||||||
// return errs["user"]
|
systemID := alertRecord.GetString("system")
|
||||||
// }
|
|
||||||
// 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", systemName),
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,17 +64,32 @@ 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
|
||||||
// IF alert is not triggered and curValue is less than threshold
|
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
|
||||||
// OR alert is triggered and curValue is greater than threshold
|
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
if lowAlert {
|
||||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
if (!triggered && val >= threshold) || (triggered && val < threshold) {
|
||||||
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")))
|
||||||
@@ -92,7 +107,11 @@ 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 {
|
||||||
alert.triggered = val > threshold
|
if lowAlert {
|
||||||
|
alert.triggered = val < threshold
|
||||||
|
} else {
|
||||||
|
alert.triggered = val > threshold
|
||||||
|
}
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -206,6 +225,19 @@ 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
|
||||||
}
|
}
|
||||||
@@ -243,12 +275,24 @@ 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 {
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
alert.triggered = true
|
lowAlert := isLowAlert(alert.name)
|
||||||
go am.sendSystemAlert(alert)
|
if lowAlert {
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
if !alert.triggered && alert.val < alert.threshold {
|
||||||
alert.triggered = false
|
alert.triggered = true
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,17 +312,26 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
// make title alert name lowercase if not CPU or GPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" {
|
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
|
lowAlert := isLowAlert(alert.name)
|
||||||
if alert.triggered {
|
if alert.triggered {
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
if lowAlert {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
if lowAlert {
|
||||||
|
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 {
|
||||||
@@ -296,9 +349,14 @@ 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", systemName),
|
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLowAlert(name string) bool {
|
||||||
|
return name == "Battery"
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package common
|
|||||||
import (
|
import (
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"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
|
||||||
@@ -18,6 +19,8 @@ const (
|
|||||||
GetContainerInfo
|
GetContainerInfo
|
||||||
// Request SMART data from agent
|
// Request SMART data from agent
|
||||||
GetSmartData
|
GetSmartData
|
||||||
|
// Request detailed systemd service info from agent
|
||||||
|
GetSystemdInfo
|
||||||
// Add new actions here...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ type AgentResponse struct {
|
|||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,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"`
|
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
@@ -65,3 +69,7 @@ type ContainerLogsRequest struct {
|
|||||||
type ContainerInfoRequest struct {
|
type ContainerInfoRequest struct {
|
||||||
ContainerID string `cbor:"0,keyasint"`
|
ContainerID string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemdInfoRequest struct {
|
||||||
|
ServiceName string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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
|
||||||
|
|
||||||
@@ -13,7 +12,24 @@ 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
|
||||||
@@ -21,10 +37,8 @@ RUN rm -rf /tmp/*
|
|||||||
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
|
||||||
|
|
||||||
# this is so we don't need to create the /tmp directory in the scratch container
|
# Copy smartmontools binaries and config files
|
||||||
COPY --from=builder /tmp /tmp
|
COPY --from=smartmontools-builder /usr/sbin/smartctl /usr/sbin/smartctl
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y smartmontools && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package smart
|
package smart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// Common types
|
// Common types
|
||||||
type VersionInfo [2]int
|
type VersionInfo [2]int
|
||||||
|
|
||||||
@@ -129,30 +135,136 @@ type AtaSmartAttributes struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AtaSmartAttribute struct {
|
type AtaSmartAttribute struct {
|
||||||
ID uint16 `json:"id"`
|
ID uint16 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Value uint16 `json:"value"`
|
Value uint16 `json:"value"`
|
||||||
Worst uint16 `json:"worst"`
|
Worst uint16 `json:"worst"`
|
||||||
Thresh uint16 `json:"thresh"`
|
Thresh uint16 `json:"thresh"`
|
||||||
WhenFailed string `json:"when_failed"`
|
WhenFailed string `json:"when_failed"`
|
||||||
Flags AttributeFlags `json:"flags"`
|
// Flags AttributeFlags `json:"flags"`
|
||||||
Raw RawValue `json:"raw"`
|
Raw RawValue `json:"raw"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttributeFlags struct {
|
// type AttributeFlags struct {
|
||||||
Value int `json:"value"`
|
// Value int `json:"value"`
|
||||||
String string `json:"string"`
|
// String string `json:"string"`
|
||||||
Prefailure bool `json:"prefailure"`
|
// Prefailure bool `json:"prefailure"`
|
||||||
UpdatedOnline bool `json:"updated_online"`
|
// UpdatedOnline bool `json:"updated_online"`
|
||||||
Performance bool `json:"performance"`
|
// Performance bool `json:"performance"`
|
||||||
ErrorRate bool `json:"error_rate"`
|
// ErrorRate bool `json:"error_rate"`
|
||||||
EventCount bool `json:"event_count"`
|
// EventCount bool `json:"event_count"`
|
||||||
AutoKeep bool `json:"auto_keep"`
|
// AutoKeep bool `json:"auto_keep"`
|
||||||
}
|
// }
|
||||||
|
|
||||||
type RawValue struct {
|
type RawValue struct {
|
||||||
Value uint64 `json:"value"`
|
Value SmartRawValue `json:"value"`
|
||||||
String string `json:"string"`
|
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 {
|
// type PowerOnTimeInfo struct {
|
||||||
@@ -163,6 +275,11 @@ type TemperatureInfo struct {
|
|||||||
Current uint8 `json:"current"`
|
Current uint8 `json:"current"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TemperatureInfoScsi struct {
|
||||||
|
Current uint8 `json:"current"`
|
||||||
|
DriveTrip uint8 `json:"drive_trip"`
|
||||||
|
}
|
||||||
|
|
||||||
// type SelectiveSelfTestTable struct {
|
// type SelectiveSelfTestTable struct {
|
||||||
// LbaMin int `json:"lba_min"`
|
// LbaMin int `json:"lba_min"`
|
||||||
// LbaMax int `json:"lba_max"`
|
// LbaMax int `json:"lba_max"`
|
||||||
@@ -211,6 +328,8 @@ type SmartInfoForSata struct {
|
|||||||
// Wwn WwnInfo `json:"wwn"`
|
// Wwn WwnInfo `json:"wwn"`
|
||||||
FirmwareVersion string `json:"firmware_version"`
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
UserCapacity UserCapacity `json:"user_capacity"`
|
||||||
|
ScsiVendor string `json:"scsi_vendor"`
|
||||||
|
ScsiProduct string `json:"scsi_product"`
|
||||||
// LogicalBlockSize int `json:"logical_block_size"`
|
// LogicalBlockSize int `json:"logical_block_size"`
|
||||||
// PhysicalBlockSize int `json:"physical_block_size"`
|
// PhysicalBlockSize int `json:"physical_block_size"`
|
||||||
// RotationRate int `json:"rotation_rate"`
|
// RotationRate int `json:"rotation_rate"`
|
||||||
@@ -233,6 +352,54 @@ type SmartInfoForSata struct {
|
|||||||
// AtaSmartSelectiveSelfTestLog AtaSmartSelectiveSelfTestLog `json:"ata_smart_selective_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 {
|
// type AtaSmartErrorLog struct {
|
||||||
// Summary SummaryInfo `json:"summary"`
|
// Summary SummaryInfo `json:"summary"`
|
||||||
// }
|
// }
|
||||||
|
|||||||
62
internal/entities/smart/smart_test.go
Normal file
62
internal/entities/smart/smart_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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,9 +3,11 @@ 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 {
|
||||||
@@ -41,9 +43,28 @@ 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,13 +144,16 @@ type Info struct {
|
|||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,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
|
// 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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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"`
|
||||||
}
|
}
|
||||||
|
|||||||
127
internal/entities/systemd/systemd.go
Normal file
127
internal/entities/systemd/systemd.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceState represents the status of a systemd service
|
||||||
|
type ServiceState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusActive ServiceState = iota
|
||||||
|
StatusInactive
|
||||||
|
StatusFailed
|
||||||
|
StatusActivating
|
||||||
|
StatusDeactivating
|
||||||
|
StatusReloading
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServiceSubState represents the sub status of a systemd service
|
||||||
|
type ServiceSubState uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubStateDead ServiceSubState = iota
|
||||||
|
SubStateRunning
|
||||||
|
SubStateExited
|
||||||
|
SubStateFailed
|
||||||
|
SubStateUnknown
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseServiceStatus converts a string status to a ServiceStatus enum value
|
||||||
|
func ParseServiceStatus(status string) ServiceState {
|
||||||
|
switch status {
|
||||||
|
case "active":
|
||||||
|
return StatusActive
|
||||||
|
case "inactive":
|
||||||
|
return StatusInactive
|
||||||
|
case "failed":
|
||||||
|
return StatusFailed
|
||||||
|
case "activating":
|
||||||
|
return StatusActivating
|
||||||
|
case "deactivating":
|
||||||
|
return StatusDeactivating
|
||||||
|
case "reloading":
|
||||||
|
return StatusReloading
|
||||||
|
default:
|
||||||
|
return StatusInactive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
|
||||||
|
func ParseServiceSubState(subState string) ServiceSubState {
|
||||||
|
switch subState {
|
||||||
|
case "dead":
|
||||||
|
return SubStateDead
|
||||||
|
case "running":
|
||||||
|
return SubStateRunning
|
||||||
|
case "exited":
|
||||||
|
return SubStateExited
|
||||||
|
case "failed":
|
||||||
|
return SubStateFailed
|
||||||
|
default:
|
||||||
|
return SubStateUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service represents a single systemd service with its stats.
|
||||||
|
type Service struct {
|
||||||
|
Name string `json:"n" cbor:"0,keyasint"`
|
||||||
|
State ServiceState `json:"s" cbor:"1,keyasint"`
|
||||||
|
Cpu float64 `json:"c" cbor:"2,keyasint"`
|
||||||
|
Mem uint64 `json:"m" cbor:"3,keyasint"`
|
||||||
|
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
|
||||||
|
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
|
||||||
|
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
|
||||||
|
PrevCpuUsage uint64 `json:"-"`
|
||||||
|
PrevReadTime time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCPUPercent calculates the CPU usage percentage for the service.
|
||||||
|
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
|
||||||
|
s.Cpu = 0
|
||||||
|
s.PrevCpuUsage = cpuUsage
|
||||||
|
s.PrevReadTime = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := now.Sub(s.PrevReadTime).Nanoseconds()
|
||||||
|
if duration <= 0 {
|
||||||
|
s.PrevCpuUsage = cpuUsage
|
||||||
|
s.PrevReadTime = now
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coreCount := int64(runtime.NumCPU())
|
||||||
|
duration *= coreCount
|
||||||
|
|
||||||
|
usageDelta := cpuUsage - s.PrevCpuUsage
|
||||||
|
cpuPercent := float64(usageDelta) / float64(duration)
|
||||||
|
s.Cpu = twoDecimals(cpuPercent * 100)
|
||||||
|
|
||||||
|
if s.Cpu > s.CpuPeak {
|
||||||
|
s.CpuPeak = s.Cpu
|
||||||
|
}
|
||||||
|
|
||||||
|
s.PrevCpuUsage = cpuUsage
|
||||||
|
s.PrevReadTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func twoDecimals(value float64) float64 {
|
||||||
|
return math.Round(value*100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceDependency represents a unit that the service depends on.
|
||||||
|
type ServiceDependency struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
ActiveState string `json:"activeState,omitempty"`
|
||||||
|
SubState string `json:"subState,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceDetails contains extended information about a systemd service.
|
||||||
|
type ServiceDetails map[string]any
|
||||||
113
internal/entities/systemd/systemd_test.go
Normal file
113
internal/entities/systemd/systemd_test.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systemd_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseServiceStatus(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected systemd.ServiceState
|
||||||
|
}{
|
||||||
|
{"active", systemd.StatusActive},
|
||||||
|
{"inactive", systemd.StatusInactive},
|
||||||
|
{"failed", systemd.StatusFailed},
|
||||||
|
{"activating", systemd.StatusActivating},
|
||||||
|
{"deactivating", systemd.StatusDeactivating},
|
||||||
|
{"reloading", systemd.StatusReloading},
|
||||||
|
{"unknown", systemd.StatusInactive}, // default case
|
||||||
|
{"", systemd.StatusInactive}, // default case
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.input, func(t *testing.T) {
|
||||||
|
result := systemd.ParseServiceStatus(test.input)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseServiceSubState(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected systemd.ServiceSubState
|
||||||
|
}{
|
||||||
|
{"dead", systemd.SubStateDead},
|
||||||
|
{"running", systemd.SubStateRunning},
|
||||||
|
{"exited", systemd.SubStateExited},
|
||||||
|
{"failed", systemd.SubStateFailed},
|
||||||
|
{"unknown", systemd.SubStateUnknown},
|
||||||
|
{"other", systemd.SubStateUnknown}, // default case
|
||||||
|
{"", systemd.SubStateUnknown}, // default case
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.input, func(t *testing.T) {
|
||||||
|
result := systemd.ParseServiceSubState(test.input)
|
||||||
|
assert.Equal(t, test.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUpdateCPUPercent(t *testing.T) {
|
||||||
|
t.Run("initial call sets CPU to 0", func(t *testing.T) {
|
||||||
|
service := &systemd.Service{}
|
||||||
|
service.UpdateCPUPercent(1000)
|
||||||
|
assert.Equal(t, 0.0, service.Cpu)
|
||||||
|
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
|
||||||
|
assert.False(t, service.PrevReadTime.IsZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
|
||||||
|
service := &systemd.Service{}
|
||||||
|
service.PrevCpuUsage = 1000
|
||||||
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
|
|
||||||
|
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
|
||||||
|
|
||||||
|
// CPU usage should be positive and reasonable
|
||||||
|
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
|
||||||
|
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
|
||||||
|
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
|
||||||
|
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CPU peak updates only when higher", func(t *testing.T) {
|
||||||
|
service := &systemd.Service{}
|
||||||
|
service.PrevCpuUsage = 1000
|
||||||
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
|
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
|
||||||
|
initialPeak := service.CpuPeak
|
||||||
|
|
||||||
|
// Now try with much lower CPU usage - should not update peak
|
||||||
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
|
service.UpdateCPUPercent(1000000) // Much lower usage
|
||||||
|
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles zero duration", func(t *testing.T) {
|
||||||
|
service := &systemd.Service{}
|
||||||
|
service.PrevCpuUsage = 1000
|
||||||
|
now := time.Now()
|
||||||
|
service.PrevReadTime = now
|
||||||
|
// Mock time.Now() to return the same time to ensure zero duration
|
||||||
|
// Since we can't mock time in Go easily, we'll check the logic manually
|
||||||
|
// The zero duration case happens when duration <= 0
|
||||||
|
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles CPU usage wraparound", func(t *testing.T) {
|
||||||
|
service := &systemd.Service{}
|
||||||
|
// Simulate wraparound where new usage is less than previous
|
||||||
|
service.PrevCpuUsage = 1000
|
||||||
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
|
service.UpdateCPUPercent(500) // Less than previous, should reset
|
||||||
|
assert.Equal(t, 0.0, service.Cpu)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -136,6 +136,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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"
|
||||||
@@ -147,6 +148,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
} else {
|
} else {
|
||||||
usersCollection.CreateRule = nil
|
usersCollection.CreateRule = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
mfaOtp, _ := GetEnv("MFA_OTP")
|
||||||
usersCollection.OTP.Length = 6
|
usersCollection.OTP.Length = 6
|
||||||
@@ -161,23 +163,37 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
if err := app.Save(usersCollection); err != nil {
|
if err := app.Save(usersCollection); err != nil {
|
||||||
return err
|
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 := app.FindCollectionByNameOrId("systems")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
var systemsReadRule string
|
||||||
systemsReadRule := "@request.auth.id != \"\""
|
if shareAllSystems == "true" {
|
||||||
if shareAllSystems != "true" {
|
systemsReadRule = "@request.auth.id != \"\""
|
||||||
// default is to only show systems that the user id is assigned to
|
} else {
|
||||||
systemsReadRule += " && users.id ?= @request.auth.id"
|
systemsReadRule = "@request.auth.id != \"\" && 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
|
||||||
return app.Save(systemsCollection)
|
if err := app.Save(systemsCollection); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -252,12 +268,17 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// get container logs
|
// refresh SMART devices for a system
|
||||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||||
// get container info
|
// get systemd service details
|
||||||
apiAuth.GET("/containers/info", h.getContainerInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// get SMART data
|
// /containers routes
|
||||||
apiAuth.GET("/smart", h.getSmartData)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +325,7 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
|||||||
|
|
||||||
data, err := fetchFunc(system, containerID)
|
data, err := fetchFunc(system, containerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
||||||
@@ -323,22 +344,46 @@ func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
|
|||||||
}, "info")
|
}, "info")
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSmartData handles GET /api/beszel/smart requests
|
// getSystemdInfo handles GET /api/beszel/systemd/info requests
|
||||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
query := e.Request.URL.Query()
|
||||||
if systemID == "" {
|
systemID := query.Get("system")
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
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)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||||
}
|
}
|
||||||
data, err := system.FetchSmartDataFromAgent()
|
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
return e.JSON(http.StatusOK, data)
|
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
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
@@ -15,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"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"
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ type System struct {
|
|||||||
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
|
||||||
|
smartOnce sync.Once // Once for fetching and saving smart devices
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -171,6 +175,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add new systemd_stats record
|
||||||
|
if len(data.SystemdServices) > 0 {
|
||||||
|
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
// 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("status", up)
|
||||||
|
|
||||||
@@ -181,14 +193,53 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch and save SMART devices when system first comes online
|
||||||
|
if err == nil {
|
||||||
|
sys.smartOnce.Do(func() {
|
||||||
|
go sys.FetchAndSaveSmartDevices()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return systemRecord, err
|
return systemRecord, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// shared params for all records
|
||||||
|
params := dbx.Params{
|
||||||
|
"system": systemId,
|
||||||
|
"updated": time.Now().UTC().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
valueStrings := make([]string, 0, len(data))
|
||||||
|
for i, service := range data {
|
||||||
|
suffix := fmt.Sprintf("%d", i)
|
||||||
|
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||||
|
params["id"+suffix] = 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
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// shared params for all records
|
||||||
params := dbx.Params{
|
params := dbx.Params{
|
||||||
"system": systemId,
|
"system": systemId,
|
||||||
"updated": time.Now().UTC().UnixMilli(),
|
"updated": time.Now().UTC().UnixMilli(),
|
||||||
@@ -340,16 +391,16 @@ func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, erro
|
|||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
||||||
// fetch via websocket
|
// fetch via websocket
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return sys.WsConn.RequestSmartData(ctx)
|
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
|
||||||
}
|
}
|
||||||
// fetch via SSH
|
|
||||||
var result map[string]any
|
var result systemd.ServiceDetails
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
||||||
stdout, err := session.StdoutPipe()
|
stdout, err := session.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -362,23 +413,38 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
|||||||
if err := session.Shell(); err != nil {
|
if err := session.Shell(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
req := common.HubRequest[any]{Action: common.GetSmartData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
req := common.HubRequest[any]{Action: common.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
|
||||||
|
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
_ = stdin.Close()
|
_ = stdin.Close()
|
||||||
|
|
||||||
var resp common.AgentResponse
|
var resp common.AgentResponse
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
// Convert to generic map for JSON response
|
if resp.ServiceInfo == nil {
|
||||||
result = make(map[string]any, len(resp.SmartData))
|
if resp.Error != "" {
|
||||||
for k, v := range resp.SmartData {
|
return false, errors.New(resp.Error)
|
||||||
result[k] = v
|
}
|
||||||
|
return false, errors.New("no systemd info in response")
|
||||||
}
|
}
|
||||||
|
result = resp.ServiceInfo
|
||||||
return false, nil
|
return false, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
return result, err
|
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.
|
||||||
|
|||||||
132
internal/hub/systems/system_smart.go
Normal file
132
internal/hub/systems/system_smart.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
75
internal/hub/systems/system_systemd_test.go
Normal file
75
internal/hub/systems/system_systemd_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetSystemdServiceId(t *testing.T) {
|
||||||
|
t.Run("deterministic output", func(t *testing.T) {
|
||||||
|
systemId := "sys-123"
|
||||||
|
serviceName := "nginx.service"
|
||||||
|
|
||||||
|
// Call multiple times and ensure same result
|
||||||
|
id1 := 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -115,8 +117,46 @@ func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string)
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
|
||||||
|
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
|
||||||
|
if !ws.IsConnected() {
|
||||||
|
return nil, gws.ErrConnClosed
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result systemd.ServiceDetails
|
||||||
|
handler := &systemdInfoHandler{result: &result}
|
||||||
|
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemdInfoHandler parses ServiceDetails from AgentResponse
|
||||||
|
type systemdInfoHandler struct {
|
||||||
|
BaseHandler
|
||||||
|
result *systemd.ServiceDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
|
if agentResponse.ServiceInfo == nil {
|
||||||
|
return errors.New("no systemd info in response")
|
||||||
|
}
|
||||||
|
*h.result = agentResponse.ServiceInfo
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
// RequestSmartData requests SMART data via WebSocket.
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
|
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]smart.SmartData, error) {
|
||||||
if !ws.IsConnected() {
|
if !ws.IsConnected() {
|
||||||
return nil, gws.ErrConnClosed
|
return nil, gws.ErrConnClosed
|
||||||
}
|
}
|
||||||
@@ -124,7 +164,7 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var result map[string]any
|
var result map[string]smart.SmartData
|
||||||
handler := ResponseHandler(&smartDataHandler{result: &result})
|
handler := ResponseHandler(&smartDataHandler{result: &result})
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
if err := ws.handleAgentRequest(req, handler); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -135,19 +175,14 @@ func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error)
|
|||||||
// smartDataHandler parses SMART data map from AgentResponse
|
// smartDataHandler parses SMART data map from AgentResponse
|
||||||
type smartDataHandler struct {
|
type smartDataHandler struct {
|
||||||
BaseHandler
|
BaseHandler
|
||||||
result *map[string]any
|
result *map[string]smart.SmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
|
||||||
if agentResponse.SmartData == nil {
|
if agentResponse.SmartData == nil {
|
||||||
return errors.New("no SMART data in response")
|
return errors.New("no SMART data in response")
|
||||||
}
|
}
|
||||||
// convert to map[string]any for transport convenience in hub layer
|
*h.result = agentResponse.SmartData
|
||||||
out := make(map[string]any, len(agentResponse.SmartData))
|
|
||||||
for k, v := range agentResponse.SmartData {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
*h.result = out
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
75
internal/hub/ws/handlers_test.go
Normal file
75
internal/hub/ws/handlers_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSystemdInfoHandlerSuccess(t *testing.T) {
|
||||||
|
handler := &systemdInfoHandler{
|
||||||
|
result: &systemd.ServiceDetails{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test successful handling with valid ServiceInfo
|
||||||
|
testDetails := systemd.ServiceDetails{
|
||||||
|
"Id": "nginx.service",
|
||||||
|
"ActiveState": "active",
|
||||||
|
"SubState": "running",
|
||||||
|
"Description": "A high performance web server",
|
||||||
|
"ExecMainPID": 1234,
|
||||||
|
"MemoryCurrent": 1024000,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := common.AgentResponse{
|
||||||
|
ServiceInfo: testDetails,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := handler.Handle(response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, testDetails, *handler.result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdInfoHandlerError(t *testing.T) {
|
||||||
|
handler := &systemdInfoHandler{
|
||||||
|
result: &systemd.ServiceDetails{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test error handling when ServiceInfo is nil
|
||||||
|
response := common.AgentResponse{
|
||||||
|
ServiceInfo: nil,
|
||||||
|
Error: "service not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := handler.Handle(response)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "no systemd info in response", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
|
||||||
|
handler := &systemdInfoHandler{
|
||||||
|
result: &systemd.ServiceDetails{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with completely empty response
|
||||||
|
response := common.AgentResponse{}
|
||||||
|
|
||||||
|
err := handler.Handle(response)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "no systemd info in response", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
|
||||||
|
handler := &systemdInfoHandler{
|
||||||
|
result: &systemd.ServiceDetails{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that legacy format is not supported
|
||||||
|
err := handler.HandleLegacy([]byte("some data"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Equal(t, "legacy format not supported", err.Error())
|
||||||
|
}
|
||||||
@@ -75,9 +75,11 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
|
"GPU",
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15",
|
||||||
|
"Battery"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -718,7 +720,9 @@ func init() {
|
|||||||
"type": "autodate"
|
"type": "autodate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [],
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_systems_status` + "`" + ` ON ` + "`" + `systems` + "`" + ` (` + "`" + `status` + "`" + `)"
|
||||||
|
],
|
||||||
"system": false
|
"system": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1005,6 +1009,436 @@ func init() {
|
|||||||
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||||
],
|
],
|
||||||
"system": false
|
"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"
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
m "github.com/pocketbase/pocketbase/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This can be deleted after Nov 2025 or so
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
m.Register(func(app core.App) error {
|
|
||||||
app.RunInTransaction(func(txApp core.App) error {
|
|
||||||
var systemIds []string
|
|
||||||
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
|
|
||||||
|
|
||||||
for _, systemId := range systemIds {
|
|
||||||
var statRecordIds []string
|
|
||||||
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
|
|
||||||
|
|
||||||
for _, statRecordId := range statRecordIds {
|
|
||||||
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var systemStats system.Stats
|
|
||||||
err = statRecord.UnmarshalJSONField("stats", &systemStats)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// if mem buff cache is less than total mem, we don't need to fix it
|
|
||||||
if systemStats.MemBuffCache < systemStats.Mem {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
systemStats.MemBuffCache = 0
|
|
||||||
statRecord.Set("stats", systemStats)
|
|
||||||
err = txApp.SaveNoValidate(statRecord)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}, func(app core.App) error {
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -177,6 +177,10 @@ 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)
|
||||||
@@ -194,6 +198,15 @@ 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
|
||||||
@@ -217,6 +230,17 @@ 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)
|
||||||
@@ -269,6 +293,10 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,6 +384,8 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +409,25 @@ 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
|
||||||
@@ -441,10 +490,18 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -510,6 +567,20 @@ 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
|
// Deletes container records that haven't been updated in the last 10 minutes
|
||||||
func deleteOldContainerRecords(app core.App) error {
|
func deleteOldContainerRecords(app core.App) error {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
@@ -524,6 +595,17 @@ func deleteOldContainerRecords(app core.App) error {
|
|||||||
return nil
|
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,6 +351,83 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||||
|
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user and system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||||
|
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "nginx.service",
|
||||||
|
"state": 0, // Active
|
||||||
|
"sub": 1, // Running
|
||||||
|
"cpu": 5.0,
|
||||||
|
"cpuPeak": 10.0,
|
||||||
|
"memory": 1024000,
|
||||||
|
"memPeak": 2048000,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Set updated time to 25 minutes ago (should be deleted)
|
||||||
|
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||||
|
err = hub.SaveNoValidate(oldRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||||
|
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "apache.service",
|
||||||
|
"state": 1, // Inactive
|
||||||
|
"sub": 0, // Dead
|
||||||
|
"cpu": 2.0,
|
||||||
|
"cpuPeak": 3.0,
|
||||||
|
"memory": 512000,
|
||||||
|
"memPeak": 1024000,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Set updated time to 10 minutes ago (should be kept)
|
||||||
|
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||||
|
err = hub.SaveNoValidate(recentRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
countBefore, err := hub.CountRecords("systemd_services")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||||
|
|
||||||
|
// Run deletion via RecordManager
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
countAfter, err := hub.CountRecords("systemd_services")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||||
|
|
||||||
|
// Verify the correct record was kept
|
||||||
|
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||||
|
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||||
|
}
|
||||||
|
|
||||||
// TestRecordManagerCreation tests RecordManager creation
|
// 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,6 +17,9 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"useButtonType": "off"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noUselessStringConcat": "error",
|
"noUselessStringConcat": "error",
|
||||||
"noUselessUndefinedInitialization": "error",
|
"noUselessUndefinedInitialization": "error",
|
||||||
@@ -30,13 +33,17 @@
|
|||||||
"noUnusedFunctionParameters": "error",
|
"noUnusedFunctionParameters": "error",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedPrivateClassMembers": "error",
|
||||||
"useExhaustiveDependencies": {
|
"useExhaustiveDependencies": {
|
||||||
"level": "error",
|
"level": "warn",
|
||||||
"options": {
|
"options": {
|
||||||
"reportUnnecessaryDependencies": false
|
"reportUnnecessaryDependencies": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"useUniqueElementIds": "off",
|
||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "warn"
|
||||||
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noParameterProperties": "error",
|
"noParameterProperties": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
@@ -47,7 +54,8 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"useAwait": "error",
|
"useAwait": "error",
|
||||||
"noEvolvingTypes": "error"
|
"noEvolvingTypes": "error",
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
1008
internal/site/bun.lock
Normal file
1008
internal/site/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
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" />
|
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||||
<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,6 +24,7 @@ export default defineConfig({
|
|||||||
"tr",
|
"tr",
|
||||||
"ru",
|
"ru",
|
||||||
"sl",
|
"sl",
|
||||||
|
"sr",
|
||||||
"sv",
|
"sv",
|
||||||
"uk",
|
"uk",
|
||||||
"vi",
|
"vi",
|
||||||
|
|||||||
317
internal/site/package-lock.json
generated
317
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.14.1",
|
"version": "0.17.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.14.1",
|
"version": "0.17.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -986,6 +987,29 @@
|
|||||||
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/balanced-match": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/brace-expansion": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/balanced-match": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1114,6 +1138,7 @@
|
|||||||
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
|
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.20.12",
|
"@babel/core": "^7.20.12",
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
@@ -1206,30 +1231,6 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lingui/cli/node_modules/glob": {
|
|
||||||
"version": "11.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
|
|
||||||
"integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"foreground-child": "^3.1.0",
|
|
||||||
"jackspeak": "^4.0.1",
|
|
||||||
"minimatch": "^10.0.0",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"package-json-from-dist": "^1.0.0",
|
|
||||||
"path-scurry": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"glob": "dist/esm/bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/glob-parent": {
|
"node_modules/@lingui/cli/node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
@@ -1243,65 +1244,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lingui/cli/node_modules/jackspeak": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/cliui": "^8.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/lru-cache": {
|
|
||||||
"version": "11.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
|
|
||||||
"integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/minimatch": {
|
|
||||||
"version": "10.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
|
|
||||||
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/path-scurry": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^11.0.0",
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/picomatch": {
|
"node_modules/@lingui/cli/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@@ -1350,6 +1292,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
|
||||||
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
|
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
"@lingui/message-utils": "5.4.1"
|
"@lingui/message-utils": "5.4.1"
|
||||||
@@ -3545,6 +3488,7 @@
|
|||||||
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -3555,6 +3499,7 @@
|
|||||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
@@ -3606,9 +3551,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.0.1",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3680,13 +3625,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -3733,16 +3671,6 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
@@ -3776,6 +3704,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -4486,13 +4415,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.2.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.0",
|
"cross-spawn": "^7.0.6",
|
||||||
"signal-exit": "^4.0.1"
|
"signal-exit": "^4.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4536,6 +4465,30 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.3.1",
|
||||||
|
"jackspeak": "^4.1.1",
|
||||||
|
"minimatch": "^10.1.1",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -4756,6 +4709,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jest-get-type": {
|
"node_modules/jest-get-type": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||||
@@ -4807,9 +4776,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5180,9 +5149,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
"node_modules/mdast-util-to-hast": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
@@ -5326,6 +5295,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/brace-expansion": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -5408,6 +5393,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
}
|
}
|
||||||
@@ -5567,6 +5553,33 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "11.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||||
|
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-type": {
|
"node_modules/path-type": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
@@ -5590,6 +5603,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5735,6 +5749,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -5744,6 +5759,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
||||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
@@ -6171,6 +6187,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -6191,16 +6217,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stringify-entities": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -6216,9 +6232,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6283,7 +6299,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
@@ -6405,6 +6422,7 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6639,6 +6657,7 @@
|
|||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6790,6 +6809,23 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -6805,13 +6841,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@@ -6825,20 +6854,10 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.14.1",
|
"version": "0.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { msg, 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,6 +36,9 @@ 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) {
|
||||||
@@ -45,10 +48,7 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
|
||||||
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,6 +124,8 @@ 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"
|
||||||
@@ -134,7 +136,11 @@ 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 ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
{system ? (
|
||||||
|
<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-145 !max-w-full p-4 sm:p-6">
|
<SheetContent className="max-h-full overflow-auto w-150 !max-w-full p-4 sm:p-6">
|
||||||
{opened && <AlertDialogContent system={system} />}
|
{opened && <AlertDialogContent system={system} />}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -245,13 +245,23 @@ 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">
|
||||||
<Trans>
|
{alertKey === "Battery" ? (
|
||||||
Average exceeds{" "}
|
<Trans>
|
||||||
<strong className="text-foreground">
|
Average drops below{" "}
|
||||||
{value}
|
<strong className="text-foreground">
|
||||||
{alertData.unit}
|
{value}
|
||||||
</strong>
|
{alertData.unit}
|
||||||
</Trans>
|
</strong>
|
||||||
|
</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,12 +11,14 @@ 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({
|
||||||
@@ -29,19 +31,25 @@ export default function AreaChartDefault({
|
|||||||
domain,
|
domain,
|
||||||
legend,
|
legend,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
|
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?: [number, number]
|
domain?: AxisDomain
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
itemSorter?: (a: any, b: any) => number
|
showTotal?: boolean
|
||||||
// logRender?: boolean
|
itemSorter?: (a: any, b: any) => number
|
||||||
}) {
|
reverseStackOrder?: 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
|
||||||
@@ -56,21 +64,29 @@ 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,
|
"opacity-100": yAxisWidth || hideYAxis,
|
||||||
|
"ps-4": hideYAxis,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
|
<AreaChart
|
||||||
|
reverseStackOrder={reverseStackOrder}
|
||||||
|
accessibilityLayer
|
||||||
|
data={chartData.systemStats}
|
||||||
|
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||||
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
{!hideYAxis && (
|
||||||
direction="ltr"
|
<YAxis
|
||||||
orientation={chartData.orientation}
|
direction="ltr"
|
||||||
className="tracking-tighter"
|
orientation={chartData.orientation}
|
||||||
width={yAxisWidth}
|
className="tracking-tighter"
|
||||||
domain={domain ?? [0, max ?? "auto"]}
|
width={yAxisWidth}
|
||||||
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
domain={domain ?? [0, max ?? "auto"]}
|
||||||
tickLine={false}
|
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
|
||||||
axisLine={false}
|
tickLine={false}
|
||||||
/>
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{xAxis(chartData)}
|
{xAxis(chartData)}
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
@@ -81,6 +97,7 @@ export default function AreaChartDefault({
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={contentFormatter}
|
contentFormatter={contentFormatter}
|
||||||
|
showTotal={showTotal}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -99,13 +116,14 @@ 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 />} />}
|
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
|
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, xAxis } from "@/components/ui/chart"
|
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, 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) + unit
|
const val = `${toFixedFloat(value, 2)}%`
|
||||||
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) {
|
||||||
@@ -124,6 +124,7 @@ 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}
|
||||||
@@ -139,7 +140,7 @@ export default memo(function ContainerChart({
|
|||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filteredKeys.has(key)
|
const filtered = filteredKeys.has(key)
|
||||||
|
|||||||
@@ -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}, 60%, 55%)`,
|
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
|
|||||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
|
||||||
}}
|
}}
|
||||||
|
showTotal={true}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
HardDriveIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home", t`All Systems`]}
|
keywords={["home"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 size-4" />
|
<ServerIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Dashboard</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
@@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</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" }))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
|
|||||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
import { MaximizeIcon, RefreshCwIcon } from "lucide-react"
|
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
@@ -35,7 +35,8 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
const syntaxTheme = "github-dark-dimmed"
|
const syntaxTheme = "github-dark-dimmed"
|
||||||
|
|
||||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||||
const [data, setData] = useState<ContainerRecord[]>([])
|
const loadTime = Date.now()
|
||||||
|
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
`sort-c-${systemId ? 1 : 0}`,
|
`sort-c-${systemId ? 1 : 0}`,
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
@@ -47,55 +48,57 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pbOptions = {
|
function fetchData(systemId?: string) {
|
||||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = (lastXMs: number) => {
|
|
||||||
const updated = Date.now() - lastXMs
|
|
||||||
let filter: string
|
|
||||||
if (systemId) {
|
|
||||||
filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated })
|
|
||||||
} else {
|
|
||||||
filter = pb.filter("updated > {:updated}", { updated })
|
|
||||||
}
|
|
||||||
pb.collection<ContainerRecord>("containers")
|
pb.collection<ContainerRecord>("containers")
|
||||||
.getList(0, 2000, {
|
.getList(0, 2000, {
|
||||||
...pbOptions,
|
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
||||||
filter,
|
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||||
})
|
})
|
||||||
.then(({ items }) => setData((curItems) => {
|
.then(
|
||||||
const containerIds = new Set(items.map(item => item.id))
|
({ items }) =>
|
||||||
const now = Date.now()
|
items.length &&
|
||||||
for (const item of curItems) {
|
setData((curItems) => {
|
||||||
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
|
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||||
items.push(item)
|
const containerIds = new Set()
|
||||||
}
|
const newItems = []
|
||||||
}
|
for (const item of items) {
|
||||||
return 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
|
// initial load
|
||||||
fetchData(70_000)
|
fetchData(systemId)
|
||||||
|
|
||||||
// if no systemId, poll every 10 seconds
|
// if no systemId, pull system containers after every system update
|
||||||
if (!systemId) {
|
if (!systemId) {
|
||||||
// poll every 10 seconds
|
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||||
const intervalId = setInterval(() => fetchData(10_500), 10_000)
|
// exclude initial load of systems
|
||||||
// clear interval on unmount
|
if (Date.now() - loadTime > 500) {
|
||||||
return () => clearInterval(intervalId)
|
fetchData(systemId)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// if systemId, fetch containers after the system is updated
|
// if systemId, fetch containers after the system is updated
|
||||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||||
setTimeout(() => fetchData(1000), 100)
|
fetchData(systemId)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: data ?? [],
|
||||||
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
|
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
@@ -148,93 +151,114 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
<Trans>Click on a container to view more information.</Trans>
|
<Trans>Click on a container to view more information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||||
placeholder={t`Filter...`}
|
<Input
|
||||||
value={globalFilter}
|
placeholder={t`Filter...`}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
value={globalFilter}
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllContainersTable = memo(
|
const AllContainersTable = memo(function AllContainersTable({
|
||||||
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
|
table,
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
rows,
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
colLength,
|
||||||
const activeContainer = useRef<ContainerRecord | null>(null)
|
data,
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
}: {
|
||||||
const openSheet = (container: ContainerRecord) => {
|
table: TableType<ContainerRecord>
|
||||||
activeContainer.current = container
|
rows: Row<ContainerRecord>[]
|
||||||
setSheetOpen(true)
|
colLength: number
|
||||||
}
|
data: ContainerRecord[] | undefined
|
||||||
|
}) {
|
||||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
count: rows.length,
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
estimateSize: () => 54,
|
const activeContainer = useRef<ContainerRecord | null>(null)
|
||||||
getScrollElement: () => scrollRef.current,
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
overscan: 5,
|
const openSheet = (container: ContainerRecord) => {
|
||||||
})
|
activeContainer.current = container
|
||||||
const virtualRows = virtualizer.getVirtualItems()
|
setSheetOpen(true)
|
||||||
|
|
||||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
|
||||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
|
||||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
|
||||||
(!rows.length || rows.length > 2) && "min-h-50"
|
|
||||||
)}
|
|
||||||
ref={scrollRef}
|
|
||||||
>
|
|
||||||
{/* add header height to table size */}
|
|
||||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
|
||||||
<table className="text-sm w-full h-full text-nowrap">
|
|
||||||
<ContainersTableHead table={table} />
|
|
||||||
<TableBody>
|
|
||||||
{rows.length ? (
|
|
||||||
virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index]
|
|
||||||
return (
|
|
||||||
<ContainerTableRow
|
|
||||||
key={row.id}
|
|
||||||
row={row}
|
|
||||||
virtualRow={virtualRow}
|
|
||||||
openSheet={openSheet}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
|
||||||
<Trans>No results.</Trans>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
const 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> {
|
async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
const [{ highlighter }, logsHtml] = await Promise.all([
|
||||||
system: container.system,
|
import("@/lib/shiki"),
|
||||||
container: container.id,
|
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
|
||||||
})])
|
system: container.system,
|
||||||
return highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme })
|
container: container.id,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return ""
|
return ""
|
||||||
@@ -243,21 +267,32 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
|
|||||||
|
|
||||||
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
async function getInfoHtml(container: ContainerRecord): Promise<string> {
|
||||||
try {
|
try {
|
||||||
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
|
let [{ highlighter }, { info }] = await Promise.all([
|
||||||
system: container.system,
|
import("@/lib/shiki"),
|
||||||
container: container.id,
|
pb.send<{ info: string }>("/api/beszel/containers/info", {
|
||||||
})])
|
system: container.system,
|
||||||
|
container: container.id,
|
||||||
|
}),
|
||||||
|
])
|
||||||
try {
|
try {
|
||||||
info = JSON.stringify(JSON.parse(info), null, 2)
|
info = JSON.stringify(JSON.parse(info), null, 2)
|
||||||
} catch (_) { }
|
} catch (_) {}
|
||||||
return highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme })
|
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpen: boolean, setSheetOpen: (open: boolean) => void, activeContainer: RefObject<ContainerRecord | null> }) {
|
function ContainerSheet({
|
||||||
|
sheetOpen,
|
||||||
|
setSheetOpen,
|
||||||
|
activeContainer,
|
||||||
|
}: {
|
||||||
|
sheetOpen: boolean
|
||||||
|
setSheetOpen: (open: boolean) => void
|
||||||
|
activeContainer: RefObject<ContainerRecord | null>
|
||||||
|
}) {
|
||||||
const container = activeContainer.current
|
const container = activeContainer.current
|
||||||
if (!container) return null
|
if (!container) return null
|
||||||
|
|
||||||
@@ -296,9 +331,9 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogsDisplay("")
|
setLogsDisplay("")
|
||||||
setInfoDisplay("");
|
setInfoDisplay("")
|
||||||
if (!container) return
|
if (!container) return
|
||||||
(async () => {
|
;(async () => {
|
||||||
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
|
||||||
setLogsDisplay(logsHtml)
|
setLogsDisplay(logsHtml)
|
||||||
setInfoDisplay(infoHtml)
|
setInfoDisplay(infoHtml)
|
||||||
@@ -327,7 +362,9 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{container.name}</SheetTitle>
|
<SheetTitle>{container.name}</SheetTitle>
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
|
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
|
||||||
|
{$allSystemsById.get()[container.system]?.name ?? ""}
|
||||||
|
</Link>
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
{container.status}
|
{container.status}
|
||||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
@@ -349,19 +386,20 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
|
|||||||
disabled={isRefreshingLogs}
|
disabled={isRefreshingLogs}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon
|
||||||
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
|
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setLogsFullscreenOpen(true)}
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<MaximizeIcon className="size-4" />
|
<MaximizeIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
|
<div
|
||||||
|
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 dangerouslySetInnerHTML={{ __html: logsDisplay }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center w-full">
|
<div className="flex items-center w-full">
|
||||||
@@ -375,15 +413,18 @@ function ContainerSheet({ sheetOpen, setSheetOpen, activeContainer }: { sheetOpe
|
|||||||
<MaximizeIcon className="size-4" />
|
<MaximizeIcon className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-sm", !infoDisplay && "animate-pulse")}>
|
<div
|
||||||
|
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 dangerouslySetInnerHTML={{ __html: infoDisplay }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,39 +446,51 @@ function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerTableRow = memo(
|
const ContainerTableRow = memo(function ContainerTableRow({
|
||||||
function ContainerTableRow({
|
row,
|
||||||
row,
|
virtualRow,
|
||||||
virtualRow,
|
openSheet,
|
||||||
openSheet,
|
}: {
|
||||||
}: {
|
row: Row<ContainerRecord>
|
||||||
row: Row<ContainerRecord>
|
virtualRow: VirtualItem
|
||||||
virtualRow: VirtualItem
|
openSheet: (container: ContainerRecord) => void
|
||||||
openSheet: (container: ContainerRecord) => void
|
}) {
|
||||||
}) {
|
return (
|
||||||
return (
|
<TableRow
|
||||||
<TableRow
|
data-state={row.getIsSelected() && "selected"}
|
||||||
data-state={row.getIsSelected() && "selected"}
|
className="cursor-pointer transition-opacity"
|
||||||
className="cursor-pointer transition-opacity"
|
onClick={() => openSheet(row.original)}
|
||||||
onClick={() => openSheet(row.original)}
|
>
|
||||||
>
|
{row.getVisibleCells().map((cell) => (
|
||||||
{row.getVisibleCells().map((cell) => (
|
<TableCell
|
||||||
<TableCell
|
key={cell.id}
|
||||||
key={cell.id}
|
className="py-0"
|
||||||
className="py-0"
|
style={{
|
||||||
style={{
|
height: virtualRow.size,
|
||||||
height: virtualRow.size,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
))}
|
</TableRow>
|
||||||
</TableRow>
|
)
|
||||||
)
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
|
function LogsFullscreenDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
logsDisplay,
|
||||||
|
containerName,
|
||||||
|
onRefresh,
|
||||||
|
isRefreshing,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
logsDisplay: string
|
||||||
|
containerName: string
|
||||||
|
onRefresh: () => void | Promise<void>
|
||||||
|
isRefreshing: boolean
|
||||||
|
}) {
|
||||||
const outerContainerRef = useRef<HTMLDivElement>(null)
|
const outerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -462,24 +515,30 @@ function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={onRefresh}
|
||||||
void onRefresh()
|
|
||||||
}}
|
|
||||||
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
title={t`Refresh`}
|
title={t`Refresh`}
|
||||||
aria-label={t`Refresh`}
|
aria-label={t`Refresh`}
|
||||||
>
|
>
|
||||||
<RefreshCwIcon
|
<RefreshCwIcon className={`size-4 transition-transform duration-300 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||||
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
|
function InfoFullscreenDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
infoDisplay,
|
||||||
|
containerName,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
infoDisplay: string
|
||||||
|
containerName: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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">
|
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
import {
|
import {
|
||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
|
HardDriveIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -29,6 +30,7 @@ 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"))
|
||||||
|
|
||||||
@@ -55,6 +57,13 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
</Link>
|
</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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
|||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
containers: "/containers",
|
||||||
|
smart: "/smart",
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
type PaginationState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
@@ -40,7 +41,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 } from "@/lib/utils"
|
import { cn, formatDuration, formatShortDate, useBrowserStorage } 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"
|
||||||
|
|
||||||
@@ -66,6 +67,12 @@ 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
|
||||||
@@ -136,12 +143,14 @@ 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) => {
|
||||||
@@ -318,10 +327,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-[4.8em]" id="rows-per-page">
|
<SelectTrigger className="w-18" id="rows-per-page">
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top">
|
<SelectContent side="top">
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
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"
|
||||||
@@ -17,6 +20,8 @@ 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()
|
||||||
@@ -73,6 +78,27 @@ 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">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from "@/components/ui/use-toast"
|
|||||||
import { isAdmin, pb } from "@/lib/api"
|
import { isAdmin, pb } from "@/lib/api"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
|
import { QuietHours } from "./quiet-hours"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -120,19 +121,32 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<div>
|
||||||
<Trans>Webhook / Push notifications</Trans>
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
</h3>
|
<Trans>Webhook / Push notifications</Trans>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
</h3>
|
||||||
<Trans>
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
Beszel uses{" "}
|
<Trans>
|
||||||
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
Beszel uses{" "}
|
||||||
Shoutrrr
|
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
|
||||||
</a>{" "}
|
Shoutrrr
|
||||||
to integrate with popular notification services.
|
</a>{" "}
|
||||||
</Trans>
|
to integrate with popular notification services.
|
||||||
</p>
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 shrink-0"
|
||||||
|
onClick={addWebhook}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Add URL</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{webhooks.length > 0 && (
|
{webhooks.length > 0 && (
|
||||||
<div className="grid gap-2.5" id="webhooks">
|
<div className="grid gap-2.5" id="webhooks">
|
||||||
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
<Separator />
|
||||||
variant="outline"
|
<div className="space-y-3">
|
||||||
size="sm"
|
<QuietHours />
|
||||||
className="mt-2 flex items-center gap-1"
|
|
||||||
onClick={addWebhook}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
|
||||||
<Trans>Add URL</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Button
|
<Button
|
||||||
@@ -194,7 +202,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-muted/40 p-2 md:p-3">
|
<Card className="bg-table-header p-2 md:p-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
534
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
534
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import {
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
ServerIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ActivityIcon,
|
||||||
|
PenSquareIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { $systems } from "@/lib/stores"
|
||||||
|
import { formatShortDate } from "@/lib/utils"
|
||||||
|
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
||||||
|
|
||||||
|
const quietHoursTranslation = t`Quiet Hours`
|
||||||
|
|
||||||
|
export function QuietHours() {
|
||||||
|
const [data, setData] = useState<QuietHoursRecord[]>([])
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const systems = useStore($systems)
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const pbOptions = {
|
||||||
|
expand: "system",
|
||||||
|
fields: "id,user,system,type,start,end,expand.system.name",
|
||||||
|
}
|
||||||
|
// Initial load
|
||||||
|
pb.collection<QuietHoursRecord>("quiet_hours")
|
||||||
|
.getList(0, 200, {
|
||||||
|
...pbOptions,
|
||||||
|
sort: "system",
|
||||||
|
})
|
||||||
|
.then(({ items }) => setData(items))
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
;(async () => {
|
||||||
|
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||||
|
"*",
|
||||||
|
(e) => {
|
||||||
|
if (e.action === "create") {
|
||||||
|
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||||
|
}
|
||||||
|
if (e.action === "update") {
|
||||||
|
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
||||||
|
}
|
||||||
|
if (e.action === "delete") {
|
||||||
|
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pbOptions
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
// Unsubscribe on unmount
|
||||||
|
return () => unsubscribe?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await pb.collection("quiet_hours").delete(id)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: (e as Error).message || "Failed to delete quiet hours.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (record: QuietHoursRecord) => {
|
||||||
|
setEditingRecord(record)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingRecord(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (record: QuietHoursRecord) => {
|
||||||
|
if (record.type === "daily") {
|
||||||
|
// For daily windows, show only time
|
||||||
|
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
return `${startTime} - ${endTime}`
|
||||||
|
}
|
||||||
|
// For one-time windows, show full date and time
|
||||||
|
const start = formatShortDate(record.start)
|
||||||
|
const end = formatShortDate(record.end)
|
||||||
|
return `${start} - ${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (record.type === "daily") {
|
||||||
|
// For daily windows, check if current time is within the window
|
||||||
|
const startDate = new Date(record.start)
|
||||||
|
const endDate = new Date(record.end)
|
||||||
|
|
||||||
|
// Get current time in local timezone
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
||||||
|
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
||||||
|
|
||||||
|
// Convert UTC to local time offset
|
||||||
|
const offset = now.getTimezoneOffset()
|
||||||
|
const localStartMinutes = (startMinutes - offset + 1440) % 1440
|
||||||
|
const localEndMinutes = (endMinutes - offset + 1440) % 1440
|
||||||
|
|
||||||
|
// Handle cases where window spans midnight
|
||||||
|
if (localStartMinutes <= localEndMinutes) {
|
||||||
|
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||||
|
} else {
|
||||||
|
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For one-time windows
|
||||||
|
const startDate = new Date(record.start)
|
||||||
|
const endDate = new Date(record.end)
|
||||||
|
|
||||||
|
if (now >= startDate && now < endDate) {
|
||||||
|
return "active"
|
||||||
|
} else if (now >= endDate) {
|
||||||
|
return "past"
|
||||||
|
} else {
|
||||||
|
return "inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-lg font-medium">{quietHoursTranslation}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border/50">
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ClockIcon className="size-4" />
|
||||||
|
<Trans>Type</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
<Trans>Schedule</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ActivityIcon className="size-4" />
|
||||||
|
<Trans>State</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 text-right sr-only">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{(() => {
|
||||||
|
const state = getWindowState(record)
|
||||||
|
const stateConfig = {
|
||||||
|
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||||
|
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||||
|
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
|
||||||
|
}
|
||||||
|
const config = stateConfig[state]
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
||||||
|
<PenSquareIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
||||||
|
function formatDateTimeLocal(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0")
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuietHoursDialog({
|
||||||
|
editingRecord,
|
||||||
|
systems,
|
||||||
|
onClose,
|
||||||
|
toast,
|
||||||
|
}: {
|
||||||
|
editingRecord: QuietHoursRecord | null
|
||||||
|
systems: SystemRecord[]
|
||||||
|
onClose: () => void
|
||||||
|
toast: ReturnType<typeof useToast>["toast"]
|
||||||
|
}) {
|
||||||
|
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
||||||
|
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
||||||
|
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
|
||||||
|
const [startDateTime, setStartDateTime] = useState("")
|
||||||
|
const [endDateTime, setEndDateTime] = useState("")
|
||||||
|
const [startTime, setStartTime] = useState("")
|
||||||
|
const [endTime, setEndTime] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingRecord) {
|
||||||
|
setSelectedSystem(editingRecord.system || "")
|
||||||
|
setIsGlobal(!editingRecord.system)
|
||||||
|
setWindowType(editingRecord.type)
|
||||||
|
if (editingRecord.type === "daily") {
|
||||||
|
// Extract time from datetime
|
||||||
|
const start = new Date(editingRecord.start)
|
||||||
|
const end = editingRecord.end ? new Date(editingRecord.end) : null
|
||||||
|
setStartTime(start.toTimeString().slice(0, 5))
|
||||||
|
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
|
||||||
|
} else {
|
||||||
|
// For one-time, format as datetime-local (local time, not UTC)
|
||||||
|
const startDate = new Date(editingRecord.start)
|
||||||
|
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
|
||||||
|
|
||||||
|
setStartDateTime(formatDateTimeLocal(startDate))
|
||||||
|
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset form with default dates: today at 12pm and 1pm
|
||||||
|
const today = new Date()
|
||||||
|
const noon = new Date(today)
|
||||||
|
noon.setHours(12, 0, 0, 0)
|
||||||
|
const onePm = new Date(today)
|
||||||
|
onePm.setHours(13, 0, 0, 0)
|
||||||
|
|
||||||
|
setSelectedSystem("")
|
||||||
|
setIsGlobal(true)
|
||||||
|
setWindowType("one-time")
|
||||||
|
setStartDateTime(formatDateTimeLocal(noon))
|
||||||
|
setEndDateTime(formatDateTimeLocal(onePm))
|
||||||
|
setStartTime("12:00")
|
||||||
|
setEndTime("13:00")
|
||||||
|
}
|
||||||
|
}, [editingRecord])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let startValue: string
|
||||||
|
let endValue: string | undefined
|
||||||
|
|
||||||
|
if (windowType === "daily") {
|
||||||
|
// For daily windows, convert local time to UTC
|
||||||
|
// Create a date with the time in local timezone, then convert to UTC
|
||||||
|
const startDate = new Date(`2000-01-01T${startTime}:00`)
|
||||||
|
startValue = startDate.toISOString()
|
||||||
|
|
||||||
|
if (endTime) {
|
||||||
|
const endDate = new Date(`2000-01-01T${endTime}:00`)
|
||||||
|
endValue = endDate.toISOString()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For one-time windows, use the datetime values
|
||||||
|
startValue = new Date(startDateTime).toISOString()
|
||||||
|
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
user: pb.authStore.record?.id,
|
||||||
|
system: isGlobal ? undefined : selectedSystem,
|
||||||
|
type: windowType,
|
||||||
|
start: startValue,
|
||||||
|
end: endValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingRecord) {
|
||||||
|
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
||||||
|
} else {
|
||||||
|
await pb.collection("quiet_hours").create(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Failed to save settings`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingRecord ? (
|
||||||
|
<Trans>Edit {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Schedule quiet hours where notifications will not be sent.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<Trans>Global</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="system" className="mt-4 space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="system">
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
||||||
|
<SelectTrigger id="system">
|
||||||
|
<SelectValue placeholder={t`Select ${{ foo: t`System`.toLocaleLowerCase() }}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{systems.map((system) => (
|
||||||
|
<SelectItem key={system.id} value={system.id}>
|
||||||
|
{system.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* Hidden input for native form validation */}
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={selectedSystem}
|
||||||
|
onChange={() => {}}
|
||||||
|
required={!isGlobal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type">
|
||||||
|
<Trans>Type</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
||||||
|
<SelectTrigger id="type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one-time">
|
||||||
|
<Trans>One-time</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="daily">
|
||||||
|
<Trans>Daily</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windowType === "one-time" ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="start-datetime">
|
||||||
|
<Trans>Start Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="start-datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDateTime}
|
||||||
|
onChange={(e) => setStartDateTime(e.target.value)}
|
||||||
|
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
||||||
|
required
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="end-datetime">
|
||||||
|
<Trans>End Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="end-datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDateTime}
|
||||||
|
onChange={(e) => setEndDateTime(e.target.value)}
|
||||||
|
min={startDateTime || formatDateTimeLocal(new Date())}
|
||||||
|
required
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="start-time">
|
||||||
|
<Trans>Start Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
id="start-time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="end-time">
|
||||||
|
<Trans>End Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
id="end-time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
internal/site/src/components/routes/smart.tsx
Normal file
20
internal/site/src/components/routes/smart.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import SmartTable from "@/components/routes/system/smart-table"
|
||||||
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
|
|
||||||
|
export default function Smart() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `S.M.A.R.T. / Beszel`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ActiveAlerts />
|
||||||
|
<SmartTable />
|
||||||
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Plural, Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
@@ -42,9 +42,9 @@ import {
|
|||||||
chartTimeData,
|
chartTimeData,
|
||||||
cn,
|
cn,
|
||||||
compareSemVer,
|
compareSemVer,
|
||||||
debounce,
|
|
||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
secondsToString,
|
||||||
getHostDisplayValue,
|
getHostDisplayValue,
|
||||||
listen,
|
listen,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
@@ -72,9 +72,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import NetworkSheet from "./system/network-sheet"
|
import NetworkSheet from "./system/network-sheet"
|
||||||
|
import CpuCoresSheet from "./system/cpu-sheet"
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
import { pinnedAxisDomain } from "../ui/chart"
|
||||||
|
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -96,8 +96,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = chartTime === "1m" ? 400 : 20_000
|
// const buffer = chartTime === "1m" ? 400 : 20_000
|
||||||
const now = new Date(Date.now() + buffer)
|
const now = new Date(Date.now())
|
||||||
const startTime = chartTimeData[chartTime].getOffset(now)
|
const startTime = chartTimeData[chartTime].getOffset(now)
|
||||||
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
|
||||||
const data = {
|
const data = {
|
||||||
@@ -358,21 +358,13 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
value: system.info.k,
|
value: system.info.k,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let uptime: React.ReactNode
|
let uptime: string
|
||||||
if (system.info.u < 3600) {
|
if (system.info.u < 3600) {
|
||||||
uptime = (
|
uptime = secondsToString(system.info.u, "minute")
|
||||||
<Plural
|
} else if (system.info.u < 360000) {
|
||||||
value={Math.trunc(system.info.u / 60)}
|
uptime = secondsToString(system.info.u, "hour")
|
||||||
one="# minute"
|
|
||||||
few="# minutes"
|
|
||||||
many="# minutes"
|
|
||||||
other="# minutes"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else if (system.info.u < 172800) {
|
|
||||||
uptime = <Plural value={Math.trunc(system.info.u / 3600)} one="# hour" other="# hours" />
|
|
||||||
} else {
|
} else {
|
||||||
uptime = <Plural value={Math.trunc(system.info?.u / 86400)} one="# day" other="# days" />
|
uptime = secondsToString(system.info.u, "day")
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
|
||||||
@@ -592,7 +584,12 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`CPU Usage`}
|
title={t`CPU Usage`}
|
||||||
description={t`Average system-wide CPU utilization`}
|
description={t`Average system-wide CPU utilization`}
|
||||||
cornerEl={maxValSelect}
|
cornerEl={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{maxValSelect}
|
||||||
|
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<AreaChartDefault
|
<AreaChartDefault
|
||||||
chartData={chartData}
|
chartData={chartData}
|
||||||
@@ -607,6 +604,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
]}
|
]}
|
||||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||||
|
domain={pinnedAxisDomain()}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -700,6 +698,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
|
showTotal={true}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -753,6 +752,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
|
||||||
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
|
||||||
}}
|
}}
|
||||||
|
showTotal={true}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -965,9 +965,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
label: t`Write`,
|
label: t`Write`,
|
||||||
dataKey: ({ stats }) => {
|
dataKey: ({ stats }) => {
|
||||||
if (showMax) {
|
if (showMax) {
|
||||||
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
||||||
}
|
}
|
||||||
return stats?.efs?.[extraFsName]?.wb ?? (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
||||||
},
|
},
|
||||||
color: 3,
|
color: 3,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
@@ -1003,15 +1003,20 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
|
||||||
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
<LazySmartTable systemId={system.id} />
|
||||||
<LazyContainersTable systemId={id} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LazySmartTable systemId={system.id} />
|
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
||||||
|
<LazyContainersTable systemId={system.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||||
|
<LazySystemdTable systemId={system.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
{/* add space for tooltip if lots of sensors */}
|
||||||
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
{bottomSpacing > 0 && <span className="block" style={{ height: bottomSpacing }} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1040,32 +1045,51 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const containerFilter = useStore(store)
|
const storeValue = useStore(store)
|
||||||
|
const [inputValue, setInputValue] = useState(storeValue)
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store])
|
useEffect(() => {
|
||||||
|
setInputValue(storeValue)
|
||||||
|
}, [storeValue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inputValue === storeValue) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const handle = window.setTimeout(() => store.set(inputValue), 80)
|
||||||
|
return () => clearTimeout(handle)
|
||||||
|
}, [inputValue, storeValue, store])
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => debouncedStoreSet(e.target.value),
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
[debouncedStoreSet]
|
const value = e.target.value
|
||||||
|
setInputValue(value)
|
||||||
|
},
|
||||||
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setInputValue("")
|
||||||
|
store.set("")
|
||||||
|
}, [store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
className="ps-4 pe-8 w-full sm:w-44"
|
className="ps-4 pe-8 w-full sm:w-44"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
value={containerFilter}
|
value={inputValue}
|
||||||
/>
|
/>
|
||||||
{containerFilter && (
|
{inputValue && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
onClick={() => store.set("")}
|
onClick={handleClear}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1125,7 +1149,7 @@ export function ChartCard({
|
|||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
|
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className={cn("ps-0 w-[calc(100%-1.5em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
<div className={cn("ps-0 w-[calc(100%-1.3em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
|
||||||
{
|
{
|
||||||
<Spinner
|
<Spinner
|
||||||
msg={empty ? t`Waiting for enough records to display` : undefined}
|
msg={empty ? t`Waiting for enough records to display` : undefined}
|
||||||
@@ -1144,7 +1168,7 @@ const ContainersTable = lazy(() => import("../containers-table/containers-table"
|
|||||||
function LazyContainersTable({ systemId }: { systemId: string }) {
|
function LazyContainersTable({ systemId }: { systemId: string }) {
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
{isIntersecting && <ContainersTable systemId={systemId} />}
|
{isIntersecting && <ContainersTable systemId={systemId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1153,10 +1177,21 @@ function LazyContainersTable({ systemId }: { systemId: string }) {
|
|||||||
const SmartTable = lazy(() => import("./system/smart-table"))
|
const SmartTable = lazy(() => import("./system/smart-table"))
|
||||||
|
|
||||||
function LazySmartTable({ systemId }: { systemId: string }) {
|
function LazySmartTable({ systemId }: { systemId: string }) {
|
||||||
const { isIntersecting, ref } = useIntersectionObserver()
|
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
{isIntersecting && <SmartTable systemId={systemId} />}
|
{isIntersecting && <SmartTable systemId={systemId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemdTable = lazy(() => import("../systemd-table/systemd-table"))
|
||||||
|
|
||||||
|
function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||||
|
const { isIntersecting, ref } = useIntersectionObserver()
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||||
|
{isIntersecting && <SystemdTable systemId={systemId} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
195
internal/site/src/components/routes/system/cpu-sheet.tsx
Normal file
195
internal/site/src/components/routes/system/cpu-sheet.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { MoreHorizontalIcon } from "lucide-react"
|
||||||
|
import { memo, useRef, useState } from "react"
|
||||||
|
import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart"
|
||||||
|
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
|
import { DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
|
||||||
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
|
import { ChartCard } from "../system"
|
||||||
|
|
||||||
|
const minAgentVersion = parseSemVer("0.15.3")
|
||||||
|
|
||||||
|
export default memo(function CpuCoresSheet({
|
||||||
|
chartData,
|
||||||
|
dataEmpty,
|
||||||
|
grid,
|
||||||
|
maxValues,
|
||||||
|
}: {
|
||||||
|
chartData: ChartData
|
||||||
|
dataEmpty: boolean
|
||||||
|
grid: boolean
|
||||||
|
maxValues: boolean
|
||||||
|
}) {
|
||||||
|
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
|
||||||
|
const hasOpened = useRef(false)
|
||||||
|
|
||||||
|
const supportsBreakdown = compareSemVer(chartData.agentVersion, minAgentVersion) >= 0
|
||||||
|
|
||||||
|
if (!supportsBreakdown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cpuCoresOpen && !hasOpened.current) {
|
||||||
|
hasOpened.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest stats snapshot
|
||||||
|
const latest = chartData.systemStats.at(-1)?.stats
|
||||||
|
const cpus = latest?.cpus ?? []
|
||||||
|
const numCores = cpus.length
|
||||||
|
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
|
||||||
|
|
||||||
|
const breakdownDataPoints = [
|
||||||
|
{
|
||||||
|
label: "System",
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
|
||||||
|
color: 3,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "User",
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
|
||||||
|
color: 1,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "IOWait",
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
|
||||||
|
color: 4,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Steal",
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
|
||||||
|
color: 5,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Idle",
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
|
||||||
|
color: 2,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t`Other`,
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||||
|
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
|
||||||
|
return total > 0 ? 100 - total : null
|
||||||
|
},
|
||||||
|
color: `hsl(80, 65%, 52%)`,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
},
|
||||||
|
] as DataPoint[]
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
|
||||||
|
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
title={t`View more`}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
{hasOpened.current && (
|
||||||
|
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||||
|
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||||
|
{hasBreakdown && (
|
||||||
|
<ChartCard
|
||||||
|
key="cpu-breakdown"
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`CPU Time Breakdown`}
|
||||||
|
description={t`Percentage of time spent in each state`}
|
||||||
|
legend={true}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
legend={true}
|
||||||
|
dataPoints={breakdownDataPoints}
|
||||||
|
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||||
|
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||||
|
reverseStackOrder={true}
|
||||||
|
itemSorter={() => 1}
|
||||||
|
domain={[0, 100]}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{numCores > 0 && (
|
||||||
|
<ChartCard
|
||||||
|
key="cpu-cores-all"
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={t`CPU Cores`}
|
||||||
|
legend={numCores < 10}
|
||||||
|
description={t`Per-core average utilization`}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
hideYAxis={true}
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
legend={numCores < 10}
|
||||||
|
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
|
||||||
|
label: `CPU ${i}`,
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
|
||||||
|
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
|
||||||
|
opacity: 0.35,
|
||||||
|
stackId: "a"
|
||||||
|
}))}
|
||||||
|
tickFormatter={(val) => `${val}%`}
|
||||||
|
contentFormatter={({ value }) => `${value}%`}
|
||||||
|
reverseStackOrder={true}
|
||||||
|
itemSorter={() => 1}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.from({ length: numCores }).map((_, i) => (
|
||||||
|
<ChartCard
|
||||||
|
key={`cpu-core-${i}`}
|
||||||
|
empty={dataEmpty}
|
||||||
|
grid={grid}
|
||||||
|
title={`CPU ${i}`}
|
||||||
|
description={t`Per-core average utilization`}
|
||||||
|
legend={false}
|
||||||
|
className="min-h-auto"
|
||||||
|
>
|
||||||
|
<AreaChartDefault
|
||||||
|
chartData={chartData}
|
||||||
|
maxToggled={maxValues}
|
||||||
|
legend={false}
|
||||||
|
dataPoints={[
|
||||||
|
{
|
||||||
|
label: t`Usage`,
|
||||||
|
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
|
||||||
|
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
|
||||||
|
opacity: 0.35,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
tickFormatter={(val) => `${val}%`}
|
||||||
|
contentFormatter={({ value }) => `${value}%`}
|
||||||
|
/>
|
||||||
|
</ChartCard>
|
||||||
|
))}
|
||||||
|
</SheetContent>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{hasOpened.current && (
|
{hasOpened.current && (
|
||||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||||
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
|
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
|||||||
|
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
|
||||||
|
import type { SystemdRecord } from "@/types"
|
||||||
|
import { ServiceStatus, ServiceStatusLabels, ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArrowUpDownIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CpuIcon,
|
||||||
|
MemoryStickIcon,
|
||||||
|
TerminalSquareIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
// import { $allSystemsById } from "@/lib/stores"
|
||||||
|
// import { useStore } from "@nanostores/react"
|
||||||
|
|
||||||
|
function getSubStateColor(subState: ServiceSubState) {
|
||||||
|
switch (subState) {
|
||||||
|
case ServiceSubState.Running:
|
||||||
|
return "bg-green-500"
|
||||||
|
case ServiceSubState.Failed:
|
||||||
|
return "bg-red-500"
|
||||||
|
case ServiceSubState.Dead:
|
||||||
|
return "bg-yellow-500"
|
||||||
|
default:
|
||||||
|
return "bg-zinc-500"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const systemdTableCols: ColumnDef<SystemdRecord>[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
|
accessorFn: (record) => record.name,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TerminalSquareIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
return <span className="ms-1.5 xl:w-50 block truncate">{getValue() as string}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// id: "system",
|
||||||
|
// accessorFn: (record) => record.system,
|
||||||
|
// sortingFn: (a, b) => {
|
||||||
|
// const allSystems = $allSystemsById.get()
|
||||||
|
// const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||||
|
// const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||||
|
// return systemNameA.localeCompare(systemNameB)
|
||||||
|
// },
|
||||||
|
// header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||||
|
// cell: ({ getValue }) => {
|
||||||
|
// const allSystems = useStore($allSystemsById)
|
||||||
|
// return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
id: "state",
|
||||||
|
accessorFn: (record) => record.state,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`State`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const statusValue = getValue() as ServiceStatus
|
||||||
|
const statusLabel = ServiceStatusLabels[statusValue] || "Unknown"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="dark:border-white/12">
|
||||||
|
<span className={cn("size-2 me-1.5 rounded-full", getStatusColor(statusValue))} />
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sub",
|
||||||
|
accessorFn: (record) => record.sub,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Sub State`} Icon={ActivityIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const subState = getValue() as ServiceSubState
|
||||||
|
const subStateLabel = ServiceSubStateLabels[subState] || "Unknown"
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="dark:border-white/12 text-xs capitalize">
|
||||||
|
<span className={cn("size-2 me-1.5 rounded-full", getSubStateColor(subState))} />
|
||||||
|
{subStateLabel}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpu",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
if (record.sub !== ServiceSubState.Running) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return record.cpu
|
||||||
|
},
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={`${t`CPU`} (10m)`} Icon={CpuIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (val < 0) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cpuPeak",
|
||||||
|
accessorFn: (record) => {
|
||||||
|
if (record.sub !== ServiceSubState.Running) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return record.cpuPeak ?? 0
|
||||||
|
},
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`CPU Peak`} Icon={CpuIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (val < 0) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memory",
|
||||||
|
accessorFn: (record) => record.memory,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
const formatted = formatBytes(val, false, undefined, false)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "memPeak",
|
||||||
|
accessorFn: (record) => record.memPeak,
|
||||||
|
invertSorting: true,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Memory Peak`} Icon={MemoryStickIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const val = getValue() as number
|
||||||
|
if (!val) {
|
||||||
|
return <span className="ms-1.5 text-muted-foreground">N/A</span>
|
||||||
|
}
|
||||||
|
const formatted = formatBytes(val, false, undefined, false)
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "updated",
|
||||||
|
invertSorting: true,
|
||||||
|
accessorFn: (record) => record.updated,
|
||||||
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const timestamp = getValue() as number
|
||||||
|
return (
|
||||||
|
<span className="ms-1.5 tabular-nums">
|
||||||
|
{hourWithSeconds(new Date(timestamp).toISOString())}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function HeaderButton({ column, name, Icon }: { column: Column<SystemdRecord>; name: string; Icon: React.ElementType }) {
|
||||||
|
const isSorted = column.getIsSorted()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="size-4" />}
|
||||||
|
{name}
|
||||||
|
<ArrowUpDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusColor(status: ServiceStatus) {
|
||||||
|
switch (status) {
|
||||||
|
case ServiceStatus.Active:
|
||||||
|
return "bg-green-500"
|
||||||
|
case ServiceStatus.Failed:
|
||||||
|
return "bg-red-500"
|
||||||
|
case ServiceStatus.Reloading:
|
||||||
|
case ServiceStatus.Activating:
|
||||||
|
case ServiceStatus.Deactivating:
|
||||||
|
return "bg-yellow-500"
|
||||||
|
default:
|
||||||
|
return "bg-zinc-500"
|
||||||
|
}
|
||||||
|
}
|
||||||
667
internal/site/src/components/systemd-table/systemd-table.tsx
Normal file
667
internal/site/src/components/systemd-table/systemd-table.tsx
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import {
|
||||||
|
type ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
type Row,
|
||||||
|
type SortingState,
|
||||||
|
type Table as TableType,
|
||||||
|
useReactTable,
|
||||||
|
type VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||||
|
import { LoaderCircleIcon } from "lucide-react"
|
||||||
|
import { listenKeys } from "nanostores"
|
||||||
|
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
|
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { ServiceStatus, ServiceStatusLabels, type ServiceSubState, ServiceSubStateLabels } from "@/lib/enums"
|
||||||
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
|
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
|
||||||
|
import type { SystemdRecord, SystemdServiceDetails } from "@/types"
|
||||||
|
import { Separator } from "../ui/separator"
|
||||||
|
|
||||||
|
export default function SystemdTable({ systemId }: { systemId?: string }) {
|
||||||
|
const loadTime = Date.now()
|
||||||
|
const [data, setData] = useState<SystemdRecord[]>([])
|
||||||
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
|
`sort-sd-${systemId ? 1 : 0}`,
|
||||||
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
|
sessionStorage
|
||||||
|
)
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
|
// clear old data when systemId changes
|
||||||
|
useEffect(() => {
|
||||||
|
return setData([])
|
||||||
|
}, [systemId])
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lastUpdated = data[0]?.updated ?? 0
|
||||||
|
|
||||||
|
function fetchData(systemId?: string) {
|
||||||
|
pb.collection<SystemdRecord>("systemd_services")
|
||||||
|
.getList(0, 2000, {
|
||||||
|
fields: "name,state,sub,cpu,cpuPeak,memory,memPeak,updated",
|
||||||
|
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
({ items }) =>
|
||||||
|
items.length &&
|
||||||
|
setData((curItems) => {
|
||||||
|
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||||
|
const systemdNames = new Set()
|
||||||
|
const newItems: SystemdRecord[] = []
|
||||||
|
for (const item of items) {
|
||||||
|
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||||
|
systemdNames.add(item.name)
|
||||||
|
newItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of curItems) {
|
||||||
|
if (!systemdNames.has(item.name) && lastUpdated - item.updated < 70_000) {
|
||||||
|
newItems.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newItems
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial load
|
||||||
|
fetchData(systemId)
|
||||||
|
|
||||||
|
// if no systemId, pull system containers after every system update
|
||||||
|
if (!systemId) {
|
||||||
|
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||||
|
// exclude initial load of systems
|
||||||
|
if (Date.now() - loadTime > 500) {
|
||||||
|
fetchData(systemId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if systemId, fetch containers after the system is updated
|
||||||
|
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||||
|
// don't fetch data if the last update is less than 9.5 minutes
|
||||||
|
if (lastUpdated > Date.now() - 9.5 * 60 * 1000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchData(systemId)
|
||||||
|
})
|
||||||
|
}, [systemId])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
// columns: systemdTableCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||||
|
columns: systemdTableCols,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
defaultColumn: {
|
||||||
|
sortUndefined: "last",
|
||||||
|
size: 100,
|
||||||
|
minSize: 0,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const service = row.original
|
||||||
|
const systemName = $allSystemsById.get()[service.system]?.name ?? ""
|
||||||
|
const name = service.name ?? ""
|
||||||
|
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
||||||
|
const subState = service.sub ?? ""
|
||||||
|
const searchString = `${systemName} ${name} ${statusLabel} ${subState}`.toLowerCase()
|
||||||
|
|
||||||
|
return (filterValue as string)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.every((term) => searchString.includes(term))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = table.getRowModel().rows
|
||||||
|
const visibleColumns = table.getVisibleLeafColumns()
|
||||||
|
|
||||||
|
const statusTotals = useMemo(() => {
|
||||||
|
const totals = [0, 0, 0, 0, 0, 0]
|
||||||
|
for (const service of data) {
|
||||||
|
totals[service.state]++
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (!data.length && !globalFilter) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6 @container w-full">
|
||||||
|
<CardHeader className="p-0 mb-4">
|
||||||
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
|
<div className="px-2 sm:px-1">
|
||||||
|
<CardTitle className="mb-2">
|
||||||
|
<Trans>Systemd Services</Trans>
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center">
|
||||||
|
<Trans>Total: {data.length}</Trans>
|
||||||
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
|
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||||
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
|
<Trans>Updated every 10 minutes.</Trans>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="rounded-md">
|
||||||
|
<AllSystemdTable table={table} rows={rows} colLength={visibleColumns.length} systemId={systemId} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AllSystemdTable = memo(function AllSystemdTable({
|
||||||
|
table,
|
||||||
|
rows,
|
||||||
|
colLength,
|
||||||
|
systemId,
|
||||||
|
}: {
|
||||||
|
table: TableType<SystemdRecord>
|
||||||
|
rows: Row<SystemdRecord>[]
|
||||||
|
colLength: number
|
||||||
|
systemId?: string
|
||||||
|
}) {
|
||||||
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const activeService = useRef<SystemdRecord | null>(null)
|
||||||
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const openSheet = (service: SystemdRecord) => {
|
||||||
|
activeService.current = service
|
||||||
|
setSheetOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||||
|
count: rows.length,
|
||||||
|
estimateSize: () => 54,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
overscan: 5,
|
||||||
|
})
|
||||||
|
const virtualRows = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||||
|
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||||
|
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||||
|
(!rows.length || rows.length > 2) && "min-h-50"
|
||||||
|
)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
{/* add header height to table size */}
|
||||||
|
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||||
|
<table className="text-sm w-full h-full text-nowrap">
|
||||||
|
<SystemdTableHead table={table} />
|
||||||
|
<TableBody>
|
||||||
|
{rows.length ? (
|
||||||
|
virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index]
|
||||||
|
return <SystemdTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
<Trans>No results.</Trans>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<SystemdSheet
|
||||||
|
sheetOpen={sheetOpen}
|
||||||
|
setSheetOpen={setSheetOpen}
|
||||||
|
activeService={activeService}
|
||||||
|
systemId={systemId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function SystemdSheet({
|
||||||
|
sheetOpen,
|
||||||
|
setSheetOpen,
|
||||||
|
activeService,
|
||||||
|
systemId,
|
||||||
|
}: {
|
||||||
|
sheetOpen: boolean
|
||||||
|
setSheetOpen: (open: boolean) => void
|
||||||
|
activeService: React.RefObject<SystemdRecord | null>
|
||||||
|
systemId?: string
|
||||||
|
}) {
|
||||||
|
const service = activeService.current
|
||||||
|
const [details, setDetails] = useState<SystemdServiceDetails | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sheetOpen || !service) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setDetails(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
pb.send<{ details: SystemdServiceDetails }>("/api/beszel/systemd/info", {
|
||||||
|
query: {
|
||||||
|
system: systemId,
|
||||||
|
service: service.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(({ details }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (details) {
|
||||||
|
setDetails(details)
|
||||||
|
} else {
|
||||||
|
setDetails(null)
|
||||||
|
setError(t`No results found.`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setError(err?.message ?? "Failed to load service details")
|
||||||
|
setDetails(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [sheetOpen, service, systemId])
|
||||||
|
|
||||||
|
if (!service) return null
|
||||||
|
|
||||||
|
const statusLabel = ServiceStatusLabels[service.state as ServiceStatus] ?? ""
|
||||||
|
const subStateLabel = ServiceSubStateLabels[service.sub as ServiceSubState] ?? ""
|
||||||
|
|
||||||
|
const notAvailable = <span className="text-muted-foreground">N/A</span>
|
||||||
|
|
||||||
|
const formatMemory = (value?: number | null) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return value === null ? t`Unlimited` : undefined
|
||||||
|
}
|
||||||
|
const { value: convertedValue, unit } = formatBytes(value, false, undefined, false)
|
||||||
|
const digits = convertedValue >= 10 ? 1 : 2
|
||||||
|
return `${decimalString(convertedValue, digits)} ${unit}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCpuTime = (ns?: number) => {
|
||||||
|
if (!ns) return undefined
|
||||||
|
const seconds = ns / 1_000_000_000
|
||||||
|
if (seconds >= 3600) {
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return [hours ? `${hours}h` : null, minutes ? `${minutes}m` : null, secs ? `${secs}s` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
if (seconds >= 60) {
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${minutes}m ${secs}s`
|
||||||
|
}
|
||||||
|
if (seconds >= 1) {
|
||||||
|
return `${decimalString(seconds, 2)}s`
|
||||||
|
}
|
||||||
|
return `${decimalString(seconds * 1000, 2)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTasks = (current?: number, max?: number) => {
|
||||||
|
const hasCurrent = typeof current === "number" && current >= 0
|
||||||
|
const hasMax = typeof max === "number" && max > 0 && max !== null
|
||||||
|
if (!hasCurrent && !hasMax) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasCurrent ? current : notAvailable}
|
||||||
|
{hasMax && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">
|
||||||
|
{`(${t`limit`}: ${max})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{max === null && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">
|
||||||
|
{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp?: number) => {
|
||||||
|
if (!timestamp) return undefined
|
||||||
|
// systemd timestamps are in microseconds, convert to milliseconds for JavaScript Date
|
||||||
|
const date = new Date(timestamp / 1000)
|
||||||
|
if (Number.isNaN(date.getTime())) return undefined
|
||||||
|
return date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeStateValue = (() => {
|
||||||
|
const stateText = details?.ActiveState
|
||||||
|
? details.SubState
|
||||||
|
? `${details.ActiveState} (${details.SubState})`
|
||||||
|
: details.ActiveState
|
||||||
|
: subStateLabel
|
||||||
|
? `${statusLabel} (${subStateLabel})`
|
||||||
|
: statusLabel
|
||||||
|
|
||||||
|
for (const [index, status] of ServiceStatusLabels.entries()) {
|
||||||
|
if (details?.ActiveState?.toLowerCase() === status.toLowerCase()) {
|
||||||
|
service.state = index as ServiceStatus
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor(service.state))} />
|
||||||
|
{stateText}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
|
||||||
|
const statusTextValue = details?.Result
|
||||||
|
|
||||||
|
const cpuTime = formatCpuTime(details?.CPUUsageNSec)
|
||||||
|
const tasks = formatTasks(details?.TasksCurrent, details?.TasksMax)
|
||||||
|
const memoryCurrent = formatMemory(details?.MemoryCurrent)
|
||||||
|
const memoryPeak = formatMemory(details?.MemoryPeak)
|
||||||
|
const memoryLimit = formatMemory(details?.MemoryLimit)
|
||||||
|
const restartsValue = typeof details?.NRestarts === "number" ? details.NRestarts : undefined
|
||||||
|
const mainPidValue = typeof details?.MainPID === "number" && details.MainPID > 0 ? details.MainPID : undefined
|
||||||
|
const execMainPidValue =
|
||||||
|
typeof details?.ExecMainPID === "number" && details.ExecMainPID > 0 && details.ExecMainPID !== details?.MainPID
|
||||||
|
? details.ExecMainPID
|
||||||
|
: undefined
|
||||||
|
const activeEnterTimestamp = formatTimestamp(details?.ActiveEnterTimestamp)
|
||||||
|
const activeExitTimestamp = formatTimestamp(details?.ActiveExitTimestamp)
|
||||||
|
const inactiveEnterTimestamp = formatTimestamp(details?.InactiveEnterTimestamp)
|
||||||
|
const execMainStartTimestamp = undefined // Property not available in current systemd interface
|
||||||
|
|
||||||
|
const renderRow = (key: string, label: ReactNode, value?: ReactNode, alwaysShow = false) => {
|
||||||
|
if (!alwaysShow && (value === undefined || value === null || value === "")) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={key} className="border-b last:border-b-0">
|
||||||
|
<td className="px-3 py-2 font-medium bg-muted dark:bg-muted/40 align-top w-35">{label}</td>
|
||||||
|
<td className="px-3 py-2">{value ?? notAvailable}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const capitalize = (str: string) => `${str.charAt(0).toUpperCase()}${str.slice(1).toLowerCase()}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
||||||
|
<SheetHeader className="p-0">
|
||||||
|
<SheetTitle>
|
||||||
|
<Trans>Service Details</Trans>
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||||
|
<Trans>Loading...</Trans>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Alert className="border-destructive/50 text-destructive dark:border-destructive/60 dark:text-destructive">
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Error</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{renderRow("name", t`Name`, service.name, true)}
|
||||||
|
{renderRow("description", t`Description`, details?.Description, true)}
|
||||||
|
{renderRow("loadState", t`Load state`, details?.LoadState, true)}
|
||||||
|
{renderRow(
|
||||||
|
"bootState",
|
||||||
|
t`Boot state`,
|
||||||
|
<div className="flex items-center">
|
||||||
|
{details?.UnitFileState}
|
||||||
|
{details?.UnitFilePreset && (
|
||||||
|
<span className="text-muted-foreground ms-1.5">(preset: {details?.UnitFilePreset})</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
true
|
||||||
|
)}
|
||||||
|
{renderRow("unitFile", t`Unit file`, details?.FragmentPath, true)}
|
||||||
|
{renderRow("active", t`Active state`, activeStateValue, true)}
|
||||||
|
{renderRow("status", t`Status`, statusTextValue, true)}
|
||||||
|
{renderRow(
|
||||||
|
"documentation",
|
||||||
|
t`Documentation`,
|
||||||
|
Array.isArray(details?.Documentation) && details.Documentation.length > 0
|
||||||
|
? details.Documentation.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Runtime Metrics</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
|
||||||
|
{renderRow("execMainPid", t`Exec main PID`, execMainPidValue)}
|
||||||
|
{renderRow("tasks", t`Tasks`, tasks, true)}
|
||||||
|
{renderRow("cpuTime", t`CPU time`, cpuTime)}
|
||||||
|
{renderRow("memory", t`Memory`, memoryCurrent, true)}
|
||||||
|
{renderRow("memoryPeak", capitalize(t`Memory Peak`), memoryPeak)}
|
||||||
|
{renderRow("memoryLimit", t`Memory limit`, memoryLimit)}
|
||||||
|
{renderRow("restarts", t`Restarts`, restartsValue, true)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Relationships</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{renderRow(
|
||||||
|
"wants",
|
||||||
|
t`Wants`,
|
||||||
|
Array.isArray(details?.Wants) && details.Wants.length > 0 ? details.Wants.join(", ") : undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"requires",
|
||||||
|
t`Requires`,
|
||||||
|
Array.isArray(details?.Requires) && details.Requires.length > 0
|
||||||
|
? details.Requires.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"requiredBy",
|
||||||
|
t`Required by`,
|
||||||
|
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
|
||||||
|
? details.RequiredBy.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"conflicts",
|
||||||
|
t`Conflicts`,
|
||||||
|
Array.isArray(details?.Conflicts) && details.Conflicts.length > 0
|
||||||
|
? details.Conflicts.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"before",
|
||||||
|
t`Before`,
|
||||||
|
Array.isArray(details?.Before) && details.Before.length > 0 ? details.Before.join(", ") : undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"after",
|
||||||
|
t`After`,
|
||||||
|
Array.isArray(details?.After) && details.After.length > 0 ? details.After.join(", ") : undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"triggers",
|
||||||
|
t`Triggers`,
|
||||||
|
Array.isArray(details?.Triggers) && details.Triggers.length > 0
|
||||||
|
? details.Triggers.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
{renderRow(
|
||||||
|
"triggeredBy",
|
||||||
|
t`Triggered by`,
|
||||||
|
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
|
||||||
|
? details.TriggeredBy.join(", ")
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Lifecycle</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{renderRow("activeSince", t`Became active`, activeEnterTimestamp)}
|
||||||
|
{service.state !== ServiceStatus.Active &&
|
||||||
|
renderRow("lastActive", t`Exited active`, activeExitTimestamp)}
|
||||||
|
{renderRow("inactiveSince", t`Became inactive`, inactiveEnterTimestamp)}
|
||||||
|
{renderRow("execMainStart", t`Process started`, execMainStartTimestamp)}
|
||||||
|
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
|
||||||
|
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden has-[tr]:block">
|
||||||
|
<h3 className="text-sm font-medium mb-3">
|
||||||
|
<Trans>Capabilities</Trans>
|
||||||
|
</h3>
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{renderRow("canStart", t`Can start`, details?.CanStart ? t`Yes` : t`No`)}
|
||||||
|
{renderRow("canStop", t`Can stop`, details?.CanStop ? t`Yes` : t`No`)}
|
||||||
|
{renderRow("canReload", t`Can reload`, details?.CanReload ? t`Yes` : t`No`)}
|
||||||
|
{/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? t`Yes` : t`No`)}
|
||||||
|
{renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
|
||||||
|
return (
|
||||||
|
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<tr key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead className="px-2" key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemdTableRow = memo(function SystemdTableRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
openSheet,
|
||||||
|
}: {
|
||||||
|
row: Row<SystemdRecord>
|
||||||
|
virtualRow: VirtualItem
|
||||||
|
openSheet: (service: SystemdRecord) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer transition-opacity"
|
||||||
|
onClick={() => openSheet(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="py-0"
|
||||||
|
style={{
|
||||||
|
height: virtualRow.size,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** biome-ignore-all lint/correctness/useHookAtTopLevel: <explanation> */
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -16,10 +17,12 @@ import {
|
|||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
TerminalSquareIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
@@ -68,7 +71,7 @@ const STATUS_COLORS = {
|
|||||||
* @param viewMode - "table" or "grid"
|
* @param viewMode - "table" or "grid"
|
||||||
* @returns - Column definitions for the systems table
|
* @returns - Column definitions for the systems table
|
||||||
*/
|
*/
|
||||||
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
// size: 200,
|
// size: 200,
|
||||||
@@ -133,7 +136,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.cpu,
|
accessorFn: ({ info }) => info.cpu || undefined,
|
||||||
id: "cpu",
|
id: "cpu",
|
||||||
name: () => t`CPU`,
|
name: () => t`CPU`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -142,7 +145,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// accessorKey: "info.mp",
|
// accessorKey: "info.mp",
|
||||||
accessorFn: ({ info }) => info.mp,
|
accessorFn: ({ info }) => info.mp || undefined,
|
||||||
id: "memory",
|
id: "memory",
|
||||||
name: () => t`Memory`,
|
name: () => t`Memory`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -150,15 +153,15 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.dp,
|
accessorFn: ({ info }) => info.dp || undefined,
|
||||||
id: "disk",
|
id: "disk",
|
||||||
name: () => t`Disk`,
|
name: () => t`Disk`,
|
||||||
cell: TableCellWithMeter,
|
cell: DiskCellWithMultiple,
|
||||||
Icon: HardDriveIcon,
|
Icon: HardDriveIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.g,
|
accessorFn: ({ info }) => info.g || undefined,
|
||||||
id: "gpu",
|
id: "gpu",
|
||||||
name: () => "GPU",
|
name: () => "GPU",
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -171,9 +174,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
// TODO: remove this in future release in favor of la array
|
// TODO: remove this in future release in favor of la array
|
||||||
if (!sum) {
|
if (!sum) {
|
||||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
|
||||||
}
|
}
|
||||||
return sum
|
return sum || undefined
|
||||||
},
|
},
|
||||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -216,7 +219,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
|
||||||
id: "net",
|
id: "net",
|
||||||
name: () => t`Net`,
|
name: () => t`Net`,
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -228,7 +231,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
if (sys.status === SystemStatus.Paused) {
|
if (sys.status === SystemStatus.Paused) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
@@ -258,11 +261,49 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorFn: ({ info }) => info.sv?.[0],
|
||||||
|
id: "services",
|
||||||
|
name: () => t`Services`,
|
||||||
|
size: 50,
|
||||||
|
Icon: TerminalSquareIcon,
|
||||||
|
header: sortableHeader,
|
||||||
|
hideSort: true,
|
||||||
|
sortingFn: (a, b) => {
|
||||||
|
// sort priorities: 1) failed services, 2) total services
|
||||||
|
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
|
||||||
|
const [totalCountB, numFailedB] = b.original.info.sv ?? [0, 0]
|
||||||
|
if (numFailedA !== numFailedB) {
|
||||||
|
return numFailedA - numFailedB
|
||||||
|
}
|
||||||
|
return totalCountA - totalCountB
|
||||||
|
},
|
||||||
|
cell(info) {
|
||||||
|
const sys = info.row.original
|
||||||
|
const [totalCount, numFailed] = sys.info.sv ?? [0, 0]
|
||||||
|
if (sys.status !== SystemStatus.Up || totalCount === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums whitespace-nowrap flex gap-1.5 items-center">
|
||||||
|
<span
|
||||||
|
className={cn("block size-2 rounded-full", {
|
||||||
|
[STATUS_COLORS[SystemStatus.Down]]: numFailed > 0,
|
||||||
|
[STATUS_COLORS[SystemStatus.Up]]: numFailed === 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{totalCount}{" "}
|
||||||
|
<span className="text-muted-foreground text-sm -ms-0.5">
|
||||||
|
({t`Failed`.toLowerCase()}: {numFailed})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.v,
|
accessorFn: ({ info }) => info.v,
|
||||||
id: "agent",
|
id: "agent",
|
||||||
name: () => t`Agent`,
|
name: () => t`Agent`,
|
||||||
// invertSorting: true,
|
|
||||||
size: 50,
|
size: 50,
|
||||||
Icon: WifiIcon,
|
Icon: WifiIcon,
|
||||||
hideSort: true,
|
hideSort: true,
|
||||||
@@ -354,6 +395,101 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const { info: sysInfo, status, id } = info.row.original
|
||||||
|
const extraFs = Object.entries(sysInfo.efs ?? {})
|
||||||
|
|
||||||
|
if (extraFs.length === 0) {
|
||||||
|
return TableCellWithMeter(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootDiskPct = sysInfo.dp
|
||||||
|
|
||||||
|
// sort extra disks by percentage descending
|
||||||
|
extraFs.sort((a, b) => b[1] - a[1])
|
||||||
|
|
||||||
|
function getIndicatorColor(pct: number) {
|
||||||
|
const threshold = getMeterState(pct)
|
||||||
|
return (
|
||||||
|
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
|
STATUS_COLORS.down
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMeterClass(pct: number) {
|
||||||
|
return cn("h-full", getIndicatorColor(pct))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extra disk indicators (max 3 dots - one per state if any disk exists in range)
|
||||||
|
const stateColors = [STATUS_COLORS.up, STATUS_COLORS.pending, STATUS_COLORS.down]
|
||||||
|
const extraDiskIndicators =
|
||||||
|
status !== SystemStatus.Up
|
||||||
|
? []
|
||||||
|
: [...new Set(extraFs.map(([, pct]) => getMeterState(pct)))].sort().map((state) => stateColors[state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { id })}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="flex flex-col gap-0.5 w-full relative z-10"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
|
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-8 flex items-center gap-0.5 px-1 justify-end bg-muted h-[1em] rounded-sm overflow-hidden relative">
|
||||||
|
{/* Root disk */}
|
||||||
|
<span
|
||||||
|
className={cn("absolute inset-0", getMeterClass(rootDiskPct))}
|
||||||
|
style={{ width: `${rootDiskPct}%` }}
|
||||||
|
></span>
|
||||||
|
{/* Extra disk indicators */}
|
||||||
|
{extraDiskIndicators.map((color) => (
|
||||||
|
<span
|
||||||
|
key={color}
|
||||||
|
className={cn("size-1.5 rounded-full shrink-0 outline-[0.5px] outline-muted", color)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs pb-2">
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wide tabular-nums">
|
||||||
|
<Trans context="Root disk label">Root</Trans>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||||
|
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||||
|
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{extraFs.map(([name, pct]) => {
|
||||||
|
return (
|
||||||
|
<div key={name} className="grid gap-0.5">
|
||||||
|
<div className="text-[0.65rem] max-w-40 text-muted-foreground uppercase tracking-wide truncate">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||||
|
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-12 grid bg-muted h-2.5 rounded-sm overflow-hidden">
|
||||||
|
<span className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -47,7 +48,7 @@ import type { SystemRecord } from "@/types"
|
|||||||
import AlertButton from "../alerts/alert-button"
|
import AlertButton from "../alerts/alert-button"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | SystemRecord["status"]
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
@@ -60,7 +61,7 @@ export default function SystemsTable() {
|
|||||||
const upSystems = $upSystems.get()
|
const upSystems = $upSystems.get()
|
||||||
const pausedSystems = $pausedSystems.get()
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>()
|
const [filter, setFilter] = useState<string>("")
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
"sortMode",
|
"sortMode",
|
||||||
@@ -145,7 +146,26 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<div className="flex gap-2 ms-auto w-full md:w-80">
|
||||||
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
value={filter}
|
||||||
|
className="ps-4 pe-10 w-full"
|
||||||
|
/>
|
||||||
|
{filter && (
|
||||||
|
<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={() => setFilter("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
@@ -278,6 +298,7 @@ export default function SystemsTable() {
|
|||||||
upSystemsLength,
|
upSystemsLength,
|
||||||
downSystemsLength,
|
downSystemsLength,
|
||||||
pausedSystemsLength,
|
pausedSystemsLength,
|
||||||
|
filter,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import * as RechartsPrimitive from "recharts"
|
import * as RechartsPrimitive from "recharts"
|
||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
|
import { Separator } from "./separator"
|
||||||
|
import { AxisDomain } from "recharts/types/util/types"
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -100,6 +103,8 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
filter?: string
|
filter?: string
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
|
showTotal?: boolean
|
||||||
|
totalLabel?: React.ReactNode
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -121,11 +126,16 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
itemSorter,
|
itemSorter,
|
||||||
contentFormatter: content = undefined,
|
contentFormatter: content = undefined,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
|
showTotal = false,
|
||||||
|
totalLabel,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
const config = {}
|
const config = {}
|
||||||
|
const { t } = useLingui()
|
||||||
|
const totalLabelNode = totalLabel ?? t`Total`
|
||||||
|
const totalName = typeof totalLabelNode === "string" ? totalLabelNode : t`Total`
|
||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
@@ -141,6 +151,76 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
}
|
}
|
||||||
}, [itemSorter, payload])
|
}, [itemSorter, payload])
|
||||||
|
|
||||||
|
const totalValueDisplay = React.useMemo(() => {
|
||||||
|
if (!showTotal || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalValue = 0
|
||||||
|
let hasNumericValue = false
|
||||||
|
const aggregatedNestedValues: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const item of payload) {
|
||||||
|
const numericValue = typeof item.value === "number" ? item.value : Number(item.value)
|
||||||
|
if (Number.isFinite(numericValue)) {
|
||||||
|
totalValue += numericValue
|
||||||
|
hasNumericValue = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content && item?.payload) {
|
||||||
|
const payloadKey = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const nestedPayload = (item.payload as Record<string, unknown> | undefined)?.[payloadKey]
|
||||||
|
|
||||||
|
if (nestedPayload && typeof nestedPayload === "object") {
|
||||||
|
for (const [nestedKey, nestedValue] of Object.entries(nestedPayload)) {
|
||||||
|
if (typeof nestedValue === "number" && Number.isFinite(nestedValue)) {
|
||||||
|
aggregatedNestedValues[nestedKey] = (aggregatedNestedValues[nestedKey] ?? 0) + nestedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNumericValue) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalKey = "__total__"
|
||||||
|
const totalItem: any = {
|
||||||
|
value: totalValue,
|
||||||
|
name: totalName,
|
||||||
|
dataKey: totalKey,
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
const basePayload =
|
||||||
|
payload[0]?.payload && typeof payload[0].payload === "object"
|
||||||
|
? { ...(payload[0].payload as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
totalItem.payload = {
|
||||||
|
...basePayload,
|
||||||
|
[totalKey]: aggregatedNestedValues,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof formatter === "function") {
|
||||||
|
return formatter(
|
||||||
|
totalValue,
|
||||||
|
totalName,
|
||||||
|
totalItem,
|
||||||
|
payload.length,
|
||||||
|
totalItem.payload ?? payload[0]?.payload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content) {
|
||||||
|
return content(totalItem, totalKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${totalValue.toLocaleString()}${unit ?? ""}`
|
||||||
|
}, [color, content, formatter, nameKey, payload, showTotal, totalName, unit])
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
const tooltipLabel = React.useMemo(() => {
|
||||||
if (hideLabel || !payload?.length) {
|
if (hideLabel || !payload?.length) {
|
||||||
return null
|
return null
|
||||||
@@ -242,6 +322,15 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{totalValueDisplay ? (
|
||||||
|
<>
|
||||||
|
<Separator className="mt-0.5" />
|
||||||
|
<div className="flex items-center justify-between gap-2 -mt-0.75 font-medium">
|
||||||
|
<span className="text-muted-foreground ps-3">{totalLabelNode}</span>
|
||||||
|
<span>{totalValueDisplay}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -257,14 +346,17 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
|
reverse?: boolean
|
||||||
}
|
}
|
||||||
>(({ className, payload, verticalAlign = "bottom" }, ref) => {
|
>(({ className, payload, verticalAlign = "bottom", reverse = false }, ref) => {
|
||||||
// const { config } = useChart()
|
// const { config } = useChart()
|
||||||
|
|
||||||
if (!payload?.length) {
|
if (!payload?.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reversedPayload = reverse ? [...payload].reverse() : payload
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -274,7 +366,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{payload.map((item) => {
|
{reversedPayload.map((item) => {
|
||||||
// const key = `${nameKey || item.dataKey || 'value'}`
|
// const key = `${nameKey || item.dataKey || 'value'}`
|
||||||
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
// const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
@@ -363,3 +455,15 @@ export {
|
|||||||
xAxis,
|
xAxis,
|
||||||
// ChartStyle,
|
// ChartStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pinnedAxisDomain(): AxisDomain {
|
||||||
|
return [0, (dataMax: number) => {
|
||||||
|
if (dataMax > 10) {
|
||||||
|
return Math.round(dataMax)
|
||||||
|
}
|
||||||
|
if (dataMax > 1) {
|
||||||
|
return Math.round(dataMax / 0.1) * 0.1
|
||||||
|
}
|
||||||
|
return dataMax
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -139,3 +139,11 @@ export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BatteryIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
|
||||||
|
<path d="M176,32H80A24,24,0,0,0,56,56V224a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V56A24,24,0,0,0,176,32Zm8,192a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8h96a8,8,0,0,1,8,8Zm-16-24a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,200ZM88,8a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,8Zm80,152a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Z"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
--chart-4: hsl(280 65% 60%);
|
--chart-4: hsl(280 65% 60%);
|
||||||
--chart-5: hsl(340 75% 55%);
|
--chart-5: hsl(340 75% 55%);
|
||||||
--table-header: hsl(225, 6%, 97%);
|
--table-header: hsl(225, 6%, 97%);
|
||||||
|
--chart-saturation: 65%;
|
||||||
|
--chart-lightness: 50%;
|
||||||
|
--container: 1500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -51,11 +54,13 @@
|
|||||||
--accent: hsl(220 5% 15.5%);
|
--accent: hsl(220 5% 15.5%);
|
||||||
--accent-foreground: hsl(220 2% 98%);
|
--accent-foreground: hsl(220 2% 98%);
|
||||||
--destructive: hsl(0 62% 46%);
|
--destructive: hsl(0 62% 46%);
|
||||||
--border: hsl(220 3% 16%);
|
--border: hsl(220 3% 17%);
|
||||||
--input: hsl(220 4% 22%);
|
--input: hsl(220 4% 22%);
|
||||||
--ring: hsl(220 4% 80%);
|
--ring: hsl(220 4% 80%);
|
||||||
--table-header: hsl(220, 6%, 13%);
|
--table-header: hsl(220, 6%, 13%);
|
||||||
--radius: 0.8rem;
|
--radius: 0.8rem;
|
||||||
|
--chart-saturation: 60%;
|
||||||
|
--chart-lightness: 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -112,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
@@ -137,6 +141,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-variant-ligatures: no-contextual;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -145,7 +150,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility container {
|
@utility container {
|
||||||
@apply max-w-370 mx-auto px-4;
|
max-width: var(--container);
|
||||||
|
@apply mx-auto px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility link {
|
@utility link {
|
||||||
@@ -155,10 +161,6 @@
|
|||||||
@utility ns-dialog {
|
@utility ns-dialog {
|
||||||
/* New system dialog width */
|
/* New system dialog width */
|
||||||
min-width: 30.3rem;
|
min-width: 30.3rem;
|
||||||
|
|
||||||
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
|
||||||
min-width: 27.9rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
@@ -168,4 +170,4 @@
|
|||||||
|
|
||||||
.recharts-yAxis {
|
.recharts-yAxis {
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
||||||
import type { RecordSubscription } from "pocketbase"
|
import type { RecordSubscription } from "pocketbase"
|
||||||
import { EthernetIcon } from "@/components/ui/icons"
|
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import type { AlertInfo, AlertRecord } from "@/types"
|
import type { AlertInfo, AlertRecord } from "@/types"
|
||||||
import { pb } from "./api"
|
import { pb } from "./api"
|
||||||
|
import { ThermometerIcon, BatteryIcon, HourglassIcon } from "@/components/ui/icons"
|
||||||
|
|
||||||
/** Alert info for each alert type */
|
/** Alert info for each alert type */
|
||||||
export const alertInfo: Record<string, AlertInfo> = {
|
export const alertInfo: Record<string, AlertInfo> = {
|
||||||
@@ -41,6 +42,12 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
||||||
max: 125,
|
max: 125,
|
||||||
},
|
},
|
||||||
|
GPU: {
|
||||||
|
name: () => t`GPU Usage`,
|
||||||
|
unit: "%",
|
||||||
|
icon: GpuIcon,
|
||||||
|
desc: () => t`Triggers when GPU usage exceeds a threshold`,
|
||||||
|
},
|
||||||
Temperature: {
|
Temperature: {
|
||||||
name: () => t`Temperature`,
|
name: () => t`Temperature`,
|
||||||
unit: "°C",
|
unit: "°C",
|
||||||
@@ -77,6 +84,13 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
||||||
},
|
},
|
||||||
|
Battery: {
|
||||||
|
name: () => t`Battery`,
|
||||||
|
unit: "%",
|
||||||
|
icon: BatteryIcon,
|
||||||
|
desc: () => t`Triggers when battery charge drops below a threshold`,
|
||||||
|
start: 20,
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Helper to manage user alerts */
|
/** Helper to manage user alerts */
|
||||||
|
|||||||
@@ -71,3 +71,26 @@ export enum ConnectionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const connectionTypeLabels = ["", "SSH", "WebSocket"] as const
|
export const connectionTypeLabels = ["", "SSH", "WebSocket"] as const
|
||||||
|
|
||||||
|
/** Systemd service state */
|
||||||
|
export enum ServiceStatus {
|
||||||
|
Active,
|
||||||
|
Inactive,
|
||||||
|
Failed,
|
||||||
|
Activating,
|
||||||
|
Deactivating,
|
||||||
|
Reloading,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServiceStatusLabels = ["Active", "Inactive", "Failed", "Activating", "Deactivating", "Reloading"] as const
|
||||||
|
|
||||||
|
/** Systemd service sub state */
|
||||||
|
export enum ServiceSubState {
|
||||||
|
Dead,
|
||||||
|
Running,
|
||||||
|
Exited,
|
||||||
|
Failed,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServiceSubStateLabels = ["Dead", "Running", "Exited", "Failed", "Unknown"] as const
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { messages as enMessages } from "@/locales/en/en"
|
|||||||
import { BatteryState } from "./enums"
|
import { BatteryState } from "./enums"
|
||||||
import { $direction } from "./stores"
|
import { $direction } from "./stores"
|
||||||
|
|
||||||
|
const rtlLanguages = new Set(["ar", "fa", "he"])
|
||||||
|
|
||||||
// activates locale
|
// activates locale
|
||||||
function activateLocale(locale: string, messages: Messages = enMessages) {
|
function activateLocale(locale: string, messages: Messages = enMessages) {
|
||||||
i18n.load(locale, messages)
|
i18n.load(locale, messages)
|
||||||
i18n.activate(locale)
|
i18n.activate(locale)
|
||||||
document.documentElement.lang = locale
|
document.documentElement.lang = locale
|
||||||
localStorage.setItem("lang", locale)
|
localStorage.setItem("lang", locale)
|
||||||
$direction.set(locale.startsWith("ar") || locale.startsWith("fa") ? "rtl" : "ltr")
|
$direction.set(rtlLanguages.has(locale) ? "rtl" : "ltr")
|
||||||
}
|
}
|
||||||
|
|
||||||
// dynamically loads translations for the given locale
|
// dynamically loads translations for the given locale
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ export default [
|
|||||||
label: "Français",
|
label: "Français",
|
||||||
e: "🇫🇷",
|
e: "🇫🇷",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
lang: "he",
|
||||||
|
label: "עברית",
|
||||||
|
e: "🕎",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
lang: "hr",
|
lang: "hr",
|
||||||
label: "Hrvatski",
|
label: "Hrvatski",
|
||||||
@@ -89,11 +94,6 @@ export default [
|
|||||||
label: "Português",
|
label: "Português",
|
||||||
e: "🇧🇷",
|
e: "🇧🇷",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
lang: "tr",
|
|
||||||
label: "Türkçe",
|
|
||||||
e: "🇹🇷",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
lang: "ru",
|
lang: "ru",
|
||||||
label: "Русский",
|
label: "Русский",
|
||||||
@@ -104,11 +104,21 @@ export default [
|
|||||||
label: "Slovenščina",
|
label: "Slovenščina",
|
||||||
e: "🇸🇮",
|
e: "🇸🇮",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
lang: "sr",
|
||||||
|
label: "Српски",
|
||||||
|
e: "🇷🇸",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
lang: "sv",
|
lang: "sv",
|
||||||
label: "Svenska",
|
label: "Svenska",
|
||||||
e: "🇸🇪",
|
e: "🇸🇪",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
lang: "tr",
|
||||||
|
label: "Türkçe",
|
||||||
|
e: "🇹🇷",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
lang: "uk",
|
lang: "uk",
|
||||||
label: "Українська",
|
label: "Українська",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { plural, t } from "@lingui/core/macro"
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
import { timeDay, timeHour, timeMinute } from "d3-time"
|
import { timeDay, timeHour, timeMinute } from "d3-time"
|
||||||
@@ -111,18 +111,17 @@ export const updateFavicon = (() => {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
<path fill="url(#gradient)" d="M35 70H0V0h35q4.4 0 8.2 1.7a21.4 21.4 0 0 1 6.6 4.5q2.9 2.8 4.5 6.6Q56 16.7 56 21a15.4 15.4 0 0 1-.3 3.2 17.6 17.6 0 0 1-.2.8 19.4 19.4 0 0 1-1.5 4 17 17 0 0 1-2.4 3.4 13.5 13.5 0 0 1-2.6 2.3 12.5 12.5 0 0 1-.4.3q1.7 1 3 2.5Q53 39.1 54 41a18.3 18.3 0 0 1 1.5 4 17.4 17.4 0 0 1 .5 3 15.3 15.3 0 0 1 0 1q0 4.4-1.7 8.2a21.4 21.4 0 0 1-4.5 6.6q-2.8 2.9-6.6 4.6Q39.4 70 35 70ZM14 14v14h21a7 7 0 0 0 2.3-.3 6.6 6.6 0 0 0 .4-.2Q39 27 40 26a6.9 6.9 0 0 0 1.5-2.2q.5-1.3.5-2.8a7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 17 40 16a7 7 0 0 0-2.3-1.4 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Zm0 28v14h21a7 7 0 0 0 2.3-.4 6.6 6.6 0 0 0 .4-.1Q39 54.9 40 54a7 7 0 0 0 1.5-2.2 6.9 6.9 0 0 0 .5-2.6 7.9 7.9 0 0 0 0-.2 7 7 0 0 0-.4-2.3 6.6 6.6 0 0 0-.1-.4Q40.9 45 40 44a7 7 0 0 0-2.3-1.5 6.9 6.9 0 0 0-2.5-.6 7.9 7.9 0 0 0-.2 0H14Z"/>
|
||||||
${
|
${downCount > 0 &&
|
||||||
downCount > 0 &&
|
`
|
||||||
`
|
|
||||||
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
<circle cx="40" cy="50" r="22" fill="#f00"/>
|
||||||
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
<text x="40" y="60" font-size="34" text-anchor="middle" fill="#fff" font-family="Arial" font-weight="bold">${downCount}</text>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
</svg>
|
</svg>
|
||||||
`
|
`
|
||||||
const blob = new Blob([svg], { type: "image/svg+xml" })
|
const blob = new Blob([svg], { type: "image/svg+xml" })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
;(document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
; (document.querySelector("link[rel='icon']") as HTMLLinkElement).href = url
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -288,7 +287,7 @@ export function formatBytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chartMargin = { top: 12 }
|
export const chartMargin = { top: 12, right: 5 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retuns value of system host, truncating full path if socket.
|
* Retuns value of system host, truncating full path if socket.
|
||||||
@@ -367,6 +366,12 @@ export function formatDuration(
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse semver string into major, minor, and patch numbers
|
||||||
|
* @example
|
||||||
|
* const semVer = "1.2.3"
|
||||||
|
* const { major, minor, patch } = parseSemVer(semVer)
|
||||||
|
* console.log(major, minor, patch) // 1, 2, 3
|
||||||
|
*/
|
||||||
export const parseSemVer = (semVer = ""): SemVer => {
|
export const parseSemVer = (semVer = ""): SemVer => {
|
||||||
// if (semVer.startsWith("v")) {
|
// if (semVer.startsWith("v")) {
|
||||||
// semVer = semVer.slice(1)
|
// semVer = semVer.slice(1)
|
||||||
@@ -423,3 +428,17 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
|
|||||||
return state.result
|
return state.result
|
||||||
}) as T
|
}) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format seconds to hours, minutes, or seconds */
|
||||||
|
export function secondsToString(seconds: number, unit: "hour" | "minute" | "day"): string {
|
||||||
|
const count = Math.floor(seconds / (unit === "hour" ? 3600 : unit === "minute" ? 60 : 86400))
|
||||||
|
const countString = count.toLocaleString()
|
||||||
|
switch (unit) {
|
||||||
|
case "minute":
|
||||||
|
return plural(count, { one: `${countString} minute`, few: `${countString} minutes`, many: `${countString} minutes`, other: `${countString} minutes` })
|
||||||
|
case "hour":
|
||||||
|
return plural(count, { one: `${countString} hour`, other: `${countString} hours` })
|
||||||
|
case "day":
|
||||||
|
return plural(count, { one: `${countString} day`, other: `${countString} days` })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,30 +8,15 @@ msgstr ""
|
|||||||
"Language: ar\n"
|
"Language: ar\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-11-14 22:51\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Arabic\n"
|
"Language-Team: Arabic\n"
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: ar\n"
|
"X-Crowdin-Language: ar\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
|
||||||
msgstr "{0, plural, one {# يوم} other {# أيام}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 3600)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# hour} other {# hours}}"
|
|
||||||
msgstr "{0, plural, one {# ساعة} other {# ساعات}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 60)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
|
|
||||||
msgstr "{0, plural, one {# دقيقة} few {# دقائق} many {# دقيقة} other {# دقيقة}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -39,6 +24,18 @@ msgstr "{0, plural, one {# دقيقة} few {# دقائق} many {# دقيقة} ot
|
|||||||
msgid "{0} of {1} row(s) selected."
|
msgid "{0} of {1} row(s) selected."
|
||||||
msgstr "تم تحديد {0} من {1} صف"
|
msgstr "تم تحديد {0} من {1} صف"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} day} other {{countString} days}}"
|
||||||
|
msgstr "{count, plural, one {{countString} يوم} other {{countString} أيام}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} hour} other {{countString} hours}}"
|
||||||
|
msgstr "{count, plural, one {{countString} ساعة} other {{countString} ساعات}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||||
|
msgstr "{count, plural, one {{countString} دقيقة} few {{countString} دقائق} many {{countString} دقيقة} other {{countString} دقيقة}}"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 ساعة"
|
msgstr "1 ساعة"
|
||||||
@@ -79,13 +76,16 @@ msgid "5 min"
|
|||||||
msgstr "5 دقائق"
|
msgstr "5 دقائق"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "إجراءات"
|
msgstr "إجراءات"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "نشط"
|
msgstr "نشط"
|
||||||
|
|
||||||
@@ -93,14 +93,20 @@ msgstr "نشط"
|
|||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "التنبيهات النشطة"
|
msgstr "التنبيهات النشطة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Active state"
|
||||||
|
msgstr "الحالة النشطة"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Add {foo}"
|
||||||
|
msgstr "إضافة {foo}"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add <0>System</0>"
|
msgid "Add <0>System</0>"
|
||||||
msgstr "إضافة <0>نظام</0>"
|
msgstr "إضافة <0>نظام</0>"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
|
||||||
msgid "Add New System"
|
|
||||||
msgstr "إضافة نظام جديد"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add system"
|
msgid "Add system"
|
||||||
msgstr "إضافة نظام"
|
msgstr "إضافة نظام"
|
||||||
@@ -113,11 +119,19 @@ msgstr "إضافة رابط"
|
|||||||
msgid "Adjust display options for charts."
|
msgid "Adjust display options for charts."
|
||||||
msgstr "تعديل خيارات العرض للرسوم البيانية."
|
msgstr "تعديل خيارات العرض للرسوم البيانية."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Adjust the width of the main layout"
|
||||||
|
msgstr "تعديل عرض التخطيط الرئيسي"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "مسؤول"
|
msgstr "مسؤول"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "After"
|
||||||
|
msgstr "بعد"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "وكيل"
|
msgstr "وكيل"
|
||||||
@@ -142,6 +156,7 @@ msgstr "جميع الحاويات"
|
|||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -203,6 +218,18 @@ msgstr "عرض النطاق الترددي"
|
|||||||
msgid "Battery"
|
msgid "Battery"
|
||||||
msgstr "البطارية"
|
msgstr "البطارية"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became active"
|
||||||
|
msgstr "أصبح نشطًا"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became inactive"
|
||||||
|
msgstr "أصبح غير نشط"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Before"
|
||||||
|
msgstr "قبل"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
||||||
msgstr "يدعم بيزيل بروتوكول OpenID Connect والعديد من مزوّدي المصادقة عبر بروتوكول OAuth2."
|
msgstr "يدعم بيزيل بروتوكول OpenID Connect والعديد من مزوّدي المصادقة عبر بروتوكول OAuth2."
|
||||||
@@ -220,6 +247,10 @@ msgstr "ثنائي"
|
|||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr "بت (كيلوبت/ثانية، ميجابت/ثانية، جيجابت/ثانية)"
|
msgstr "بت (كيلوبت/ثانية، ميجابت/ثانية، جيجابت/ثانية)"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Boot state"
|
||||||
|
msgstr "حالة التمهيد"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
@@ -229,11 +260,32 @@ msgstr "بايت (كيلوبايت/ثانية، ميجابايت/ثانية، ج
|
|||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can reload"
|
||||||
|
msgstr "يمكن إعادة التحميل"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can start"
|
||||||
|
msgstr "يمكن البدء"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can stop"
|
||||||
|
msgstr "يمكن الإيقاف"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "إلغاء"
|
msgstr "إلغاء"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Capabilities"
|
||||||
|
msgstr "القدرات"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "السعة"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "تحذير - فقدان محتمل للبيانات"
|
msgstr "تحذير - فقدان محتمل للبيانات"
|
||||||
@@ -275,10 +327,20 @@ msgstr "تحقق من السجلات لمزيد من التفاصيل."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
|
msgstr "تحقق من خدمة الإشعارات الخاصة بك"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Clear"
|
||||||
|
msgstr "مسح"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Click on a container to view more information."
|
msgid "Click on a container to view more information."
|
||||||
msgstr "انقر على حاوية لعرض مزيد من المعلومات."
|
msgstr "انقر على حاوية لعرض مزيد من المعلومات."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "انقر على جهاز لعرض مزيد من المعلومات."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "انقر على نظام لعرض مزيد من المعلومات."
|
msgstr "انقر على نظام لعرض مزيد من المعلومات."
|
||||||
@@ -301,6 +363,10 @@ msgstr "هيئ التنبيهات الواردة"
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "تأكيد كلمة المرور"
|
msgstr "تأكيد كلمة المرور"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Conflicts"
|
||||||
|
msgstr "التعارضات"
|
||||||
|
|
||||||
#: src/components/active-alerts.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "الاتصال مقطوع"
|
msgstr "الاتصال مقطوع"
|
||||||
@@ -361,16 +427,38 @@ msgid "Copy YAML"
|
|||||||
msgstr "نسخ YAML"
|
msgstr "نسخ YAML"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "المعالج"
|
msgstr "المعالج"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Cores"
|
||||||
|
msgstr "نوى المعالج"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "CPU Peak"
|
||||||
|
msgstr "ذروة المعالج"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "CPU time"
|
||||||
|
msgstr "وقت المعالج"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Time Breakdown"
|
||||||
|
msgstr "تفصيل وقت المعالج"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "استخدام وحدة المعالجة المركزية"
|
msgstr "استخدام وحدة المعالجة المركزية"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Create"
|
||||||
|
msgstr "إنشاء"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "إنشاء حساب"
|
msgstr "إنشاء حساب"
|
||||||
@@ -397,15 +485,23 @@ msgstr "الرفع التراكمي"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "الحالة الحالية"
|
msgstr "الحالة الحالية"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#. Power Cycles
|
||||||
msgid "Dashboard"
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgstr "لوحة التحكم"
|
msgid "Cycles"
|
||||||
|
msgstr "الدورات"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Daily"
|
||||||
|
msgstr "يوميًا"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "الفترة الزمنية الافتراضية"
|
msgstr "الفترة الزمنية الافتراضية"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "حذف"
|
msgstr "حذف"
|
||||||
@@ -414,10 +510,18 @@ msgstr "حذف"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "حذف البصمة"
|
msgstr "حذف البصمة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "الوصف"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Detail"
|
msgid "Detail"
|
||||||
msgstr "التفاصيل"
|
msgstr "التفاصيل"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "الجهاز"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -458,6 +562,7 @@ msgid "Docker Network I/O"
|
|||||||
msgstr "إدخال/إخراج الشبكة للدوكر"
|
msgstr "إدخال/إخراج الشبكة للدوكر"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Documentation"
|
msgid "Documentation"
|
||||||
msgstr "التوثيق"
|
msgstr "التوثيق"
|
||||||
|
|
||||||
@@ -481,11 +586,16 @@ msgstr "تنزيل"
|
|||||||
msgid "Duration"
|
msgid "Duration"
|
||||||
msgstr "المدة"
|
msgstr "المدة"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "تعديل"
|
msgstr "تعديل"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Edit {foo}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
@@ -501,6 +611,11 @@ msgstr "إشعارات البريد الإشباكي"
|
|||||||
msgid "Empty"
|
msgid "Empty"
|
||||||
msgstr "فارغة"
|
msgstr "فارغة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "End Time"
|
||||||
|
msgstr "وقت النهاية"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Enter email address to reset password"
|
msgid "Enter email address to reset password"
|
||||||
msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور"
|
msgstr "أدخل عنوان البريد الإشباكي لإعادة تعيين كلمة المرور"
|
||||||
@@ -517,7 +632,10 @@ msgstr "أدخل كلمة المرور لمرة واحدة الخاصة بك."
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "خطأ"
|
msgstr "خطأ"
|
||||||
|
|
||||||
@@ -528,10 +646,18 @@ msgstr "خطأ"
|
|||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
|
msgstr "يتجاوز {0}{1} في آخر {2, plural, one {# دقيقة} other {# دقائق}}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exec main PID"
|
||||||
|
msgstr "معرف العملية الرئيسي للتنفيذ"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
|
msgstr "سيتم حذف الأنظمة الحالية غير المعرفة في <0>config.yml</0>. يرجى عمل نسخ احتياطية بانتظام."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exited active"
|
||||||
|
msgstr "خرج نشطًا"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
msgstr "تصدير"
|
msgstr "تصدير"
|
||||||
@@ -548,12 +674,21 @@ msgstr "تصدير تكوين الأنظمة الحالية الخاصة بك."
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "فهرنهايت (°ف)"
|
msgstr "فهرنهايت (°ف)"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Failed"
|
||||||
|
msgstr "فشل"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "السمات الفاشلة:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "فشل في المصادقة"
|
msgstr "فشل في المصادقة"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Failed to save settings"
|
msgid "Failed to save settings"
|
||||||
msgstr "فشل في حفظ الإعدادات"
|
msgstr "فشل في حفظ الإعدادات"
|
||||||
|
|
||||||
@@ -565,9 +700,16 @@ msgstr "فشل في إرسال إشعار الاختبار"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "فشل في تحديث التنبيه"
|
msgstr "فشل في تحديث التنبيه"
|
||||||
|
|
||||||
|
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Failed: {0}"
|
||||||
|
msgstr "فشل: {0}"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "تصفية..."
|
msgstr "تصفية..."
|
||||||
@@ -576,6 +718,10 @@ msgstr "تصفية..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "البصمة"
|
msgstr "البصمة"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "البرمجيات الثابتة"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
|
msgstr "لمدة <0>{min}</0> {min, plural, one {دقيقة} other {دقائق}}"
|
||||||
@@ -601,6 +747,10 @@ msgstr "ممتلئة"
|
|||||||
msgid "General"
|
msgid "General"
|
||||||
msgstr "عام"
|
msgstr "عام"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Global"
|
||||||
|
msgstr "عالمي"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
msgstr "محركات GPU"
|
msgstr "محركات GPU"
|
||||||
@@ -609,6 +759,10 @@ msgstr "محركات GPU"
|
|||||||
msgid "GPU Power Draw"
|
msgid "GPU Power Draw"
|
||||||
msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
|
msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "GPU Usage"
|
||||||
|
msgstr "استخدام وحدة معالجة الرسوميات"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "شبكة"
|
msgstr "شبكة"
|
||||||
@@ -641,6 +795,10 @@ msgctxt "Docker image"
|
|||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr "صورة"
|
msgstr "صورة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "غير نشط"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "عنوان البريد الإشباكي غير صالح."
|
msgstr "عنوان البريد الإشباكي غير صالح."
|
||||||
@@ -658,6 +816,19 @@ msgstr "اللغة"
|
|||||||
msgid "Layout"
|
msgid "Layout"
|
||||||
msgstr "التخطيط"
|
msgstr "التخطيط"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Layout width"
|
||||||
|
msgstr "عرض التخطيط"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Lifecycle"
|
||||||
|
msgstr "دورة الحياة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "limit"
|
||||||
|
msgstr "الحد"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Load Average"
|
msgid "Load Average"
|
||||||
msgstr "متوسط التحميل"
|
msgstr "متوسط التحميل"
|
||||||
@@ -679,6 +850,14 @@ msgstr "متوسط التحميل 5 دقائق"
|
|||||||
msgid "Load Avg"
|
msgid "Load Avg"
|
||||||
msgstr "متوسط التحميل"
|
msgstr "متوسط التحميل"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Load state"
|
||||||
|
msgstr "حالة التحميل"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Loading..."
|
||||||
|
msgstr "جاري التحميل..."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "تسجيل الخروج"
|
msgstr "تسجيل الخروج"
|
||||||
@@ -702,6 +881,10 @@ msgstr "السجلات"
|
|||||||
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
||||||
msgstr "هل تبحث عن مكان لإنشاء التنبيهات؟ انقر على أيقونات الجرس <0/> في جدول الأنظمة."
|
msgstr "هل تبحث عن مكان لإنشاء التنبيهات؟ انقر على أيقونات الجرس <0/> في جدول الأنظمة."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Main PID"
|
||||||
|
msgstr "معرف العملية الرئيسي"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Manage display and notification preferences."
|
msgid "Manage display and notification preferences."
|
||||||
msgstr "إدارة تفضيلات العرض والإشعارات."
|
msgstr "إدارة تفضيلات العرض والإشعارات."
|
||||||
@@ -717,10 +900,21 @@ msgid "Max 1 min"
|
|||||||
msgstr "الحد الأقصى دقيقة"
|
msgstr "الحد الأقصى دقيقة"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "الذاكرة"
|
msgstr "الذاكرة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory limit"
|
||||||
|
msgstr "حد الذاكرة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory Peak"
|
||||||
|
msgstr "ذروة الذاكرة"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Memory Usage"
|
msgid "Memory Usage"
|
||||||
@@ -730,9 +924,15 @@ msgstr "استخدام الذاكرة"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "استخدام الذاكرة لحاويات دوكر"
|
msgstr "استخدام الذاكرة لحاويات دوكر"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "الموديل"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "الاسم"
|
msgstr "الاسم"
|
||||||
|
|
||||||
@@ -757,15 +957,30 @@ msgstr "حركة مرور الشبكة للواجهات العامة"
|
|||||||
msgid "Network unit"
|
msgid "Network unit"
|
||||||
msgstr "وحدة الشبكة"
|
msgstr "وحدة الشبكة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "No"
|
||||||
|
msgstr "لا"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "لم يتم العثور على نتائج."
|
msgstr "لم يتم العثور على نتائج."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "لا توجد نتائج."
|
msgstr "لا توجد نتائج."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "لا توجد سمات S.M.A.R.T. متاحة لهذا الجهاز."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -785,12 +1000,19 @@ msgstr "دعم OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
|
msgstr "في كل إعادة تشغيل، سيتم تحديث الأنظمة في قاعدة البيانات لتتطابق مع الأنظمة المعرفة في الملف."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "One-time"
|
||||||
|
msgstr "مرة واحدة"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "One-time password"
|
msgid "One-time password"
|
||||||
msgstr "كلمة مرور لمرة واحدة"
|
msgstr "كلمة مرور لمرة واحدة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
msgstr "فتح القائمة"
|
msgstr "فتح القائمة"
|
||||||
@@ -799,10 +1021,15 @@ msgstr "فتح القائمة"
|
|||||||
msgid "Or continue with"
|
msgid "Or continue with"
|
||||||
msgstr "أو المتابعة باستخدام"
|
msgstr "أو المتابعة باستخدام"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Other"
|
||||||
|
msgstr "أخرى"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "الكتابة فوق التنبيهات الحالية"
|
msgstr "الكتابة فوق التنبيهات الحالية"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
@@ -835,6 +1062,10 @@ msgstr "يجب أن تكون كلمة المرور أقل من 72 بايت."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
|
msgstr "تم استلام طلب إعادة تعيين كلمة المرور"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Past"
|
||||||
|
msgstr "الماضي"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "إيقاف مؤقت"
|
msgstr "إيقاف مؤقت"
|
||||||
@@ -847,6 +1078,15 @@ msgstr "متوقف مؤقتا"
|
|||||||
msgid "Paused ({pausedSystemsLength})"
|
msgid "Paused ({pausedSystemsLength})"
|
||||||
msgstr "متوقف مؤقتا ({pausedSystemsLength})"
|
msgstr "متوقف مؤقتا ({pausedSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Per-core average utilization"
|
||||||
|
msgstr "متوسط الاستخدام لكل نواة"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Percentage of time spent in each state"
|
||||||
|
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
|
msgstr "يرجى <0>تكوين خادم SMTP</0> لضمان تسليم التنبيهات."
|
||||||
@@ -884,6 +1124,11 @@ msgstr "يرجى تسجيل الدخول إلى حسابك"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "المنفذ"
|
msgstr "المنفذ"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "تشغيل الطاقة"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -893,11 +1138,19 @@ msgstr "الاستخدام الدقيق في الوقت المسجل"
|
|||||||
msgid "Preferred Language"
|
msgid "Preferred Language"
|
||||||
msgstr "اللغة المفضلة"
|
msgstr "اللغة المفضلة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Process started"
|
||||||
|
msgstr "تم بدء العملية"
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
#. Use 'Key' if your language requires many more characters
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "المفتاح العام"
|
msgstr "المفتاح العام"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Quiet Hours"
|
||||||
|
msgstr "ساعات الهدوء"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -910,9 +1163,14 @@ msgstr "تم الاستلام"
|
|||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Refresh"
|
msgid "Refresh"
|
||||||
msgstr "تحديث"
|
msgstr "تحديث"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Relationships"
|
||||||
|
msgstr "العلاقات"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "طلب كلمة مرور لمرة واحدة"
|
msgstr "طلب كلمة مرور لمرة واحدة"
|
||||||
@@ -921,6 +1179,14 @@ msgstr "طلب كلمة مرور لمرة واحدة"
|
|||||||
msgid "Request OTP"
|
msgid "Request OTP"
|
||||||
msgstr "طلب OTP"
|
msgstr "طلب OTP"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Required by"
|
||||||
|
msgstr "مطلوب من قبل"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Requires"
|
||||||
|
msgstr "يتطلب"
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "إعادة تعيين كلمة المرور"
|
msgstr "إعادة تعيين كلمة المرور"
|
||||||
@@ -931,10 +1197,19 @@ msgstr "إعادة تعيين كلمة المرور"
|
|||||||
msgid "Resolved"
|
msgid "Resolved"
|
||||||
msgstr "تم حلها"
|
msgstr "تم حلها"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Restarts"
|
||||||
|
msgstr "إعادة التشغيل"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "استئناف"
|
msgstr "استئناف"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgctxt "Root disk label"
|
||||||
|
msgid "Root"
|
||||||
|
msgstr "الجذر"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "تدوير الرمز المميز"
|
msgstr "تدوير الرمز المميز"
|
||||||
@@ -943,6 +1218,18 @@ msgstr "تدوير الرمز المميز"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "صفوف لكل صفحة"
|
msgstr "صفوف لكل صفحة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Runtime Metrics"
|
||||||
|
msgstr "مقاييس وقت التشغيل"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "تفاصيل S.M.A.R.T."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "اختبار S.M.A.R.T. الذاتي"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
|
||||||
@@ -956,6 +1243,18 @@ msgstr "حفظ الإعدادات"
|
|||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr "احفظ النظام"
|
msgstr "احفظ النظام"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule"
|
||||||
|
msgstr "جدولة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent, such as during maintenance periods."
|
||||||
|
msgstr "جدولة ساعات الهدوء حيث لن يتم إرسال الإشعارات، مثل أثناء فترات الصيانة."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent."
|
||||||
|
msgstr "جدولة ساعات الهدوء حيث لن يتم إرسال الإشعارات."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "بحث"
|
msgstr "بحث"
|
||||||
@@ -968,10 +1267,26 @@ msgstr "البحث عن الأنظمة أو الإعدادات..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
|
msgstr "راجع <0>إعدادات الإشعارات</0> لتكوين كيفية تلقي التنبيهات."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Select {foo}"
|
||||||
|
msgstr "تحديد {foo}"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "تم الإرسال"
|
msgstr "تم الإرسال"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "الرقم التسلسلي"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Service Details"
|
||||||
|
msgstr "تفاصيل الخدمة"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Services"
|
||||||
|
msgstr "الخدمات"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
|
msgstr "تعيين عتبات النسبة المئوية لألوان العداد."
|
||||||
@@ -999,17 +1314,30 @@ msgstr "إعدادات SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "الترتيب حسب"
|
msgstr "الترتيب حسب"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Start Time"
|
||||||
|
msgstr "وقت البدء"
|
||||||
|
|
||||||
#. Context: alert state (active or resolved)
|
#. Context: alert state (active or resolved)
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "الحالة"
|
msgstr "الحالة"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "الحالة"
|
msgstr "الحالة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "Sub State"
|
||||||
|
msgstr "الحالة الفرعية"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Swap space used by the system"
|
msgid "Swap space used by the system"
|
||||||
msgstr "مساحة التبديل المستخدمة من قبل النظام"
|
msgstr "مساحة التبديل المستخدمة من قبل النظام"
|
||||||
@@ -1018,9 +1346,15 @@ msgstr "مساحة التبديل المستخدمة من قبل النظام"
|
|||||||
msgid "Swap Usage"
|
msgid "Swap Usage"
|
||||||
msgstr "استخدام التبديل"
|
msgstr "استخدام التبديل"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "System"
|
msgid "System"
|
||||||
@@ -1030,6 +1364,10 @@ msgstr "النظام"
|
|||||||
msgid "System load averages over time"
|
msgid "System load averages over time"
|
||||||
msgstr "متوسط تحميل النظام مع مرور الوقت"
|
msgstr "متوسط تحميل النظام مع مرور الوقت"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Systemd Services"
|
||||||
|
msgstr "خدمات systemd"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "الأنظمة"
|
msgstr "الأنظمة"
|
||||||
@@ -1042,7 +1380,12 @@ msgstr "يمكن إدارة الأنظمة في ملف <0>config.yml</0> داخ
|
|||||||
msgid "Table"
|
msgid "Table"
|
||||||
msgstr "جدول"
|
msgstr "جدول"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Tasks"
|
||||||
|
msgstr "المهام"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "درجة الحرارة"
|
msgstr "درجة الحرارة"
|
||||||
@@ -1124,6 +1467,11 @@ msgstr "تسمح الرموز المميزة للوكلاء بالاتصال و
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
msgstr "تُستخدم الرموز المميزة والبصمات للمصادقة على اتصالات WebSocket إلى المحور."
|
||||||
|
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "الإجمالي"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
||||||
@@ -1132,6 +1480,19 @@ msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "إجمالي البيانات المرسلة لكل واجهة"
|
msgstr "إجمالي البيانات المرسلة لكل واجهة"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.length
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Total: {0}"
|
||||||
|
msgstr "الإجمالي: {0}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggered by"
|
||||||
|
msgstr "تم التفعيل بواسطة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggers"
|
||||||
|
msgstr "المحفزات"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 1 minute load average exceeds a threshold"
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل لمدة دقيقة واحدة عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز متوسط التحميل لمدة دقيقة واحدة عتبة معينة"
|
||||||
@@ -1156,6 +1517,10 @@ msgstr "يتم التفعيل عندما يتجاوز الجمع بين الصع
|
|||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
msgid "Triggers when CPU usage exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة المعالجة المركزية عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة المعالجة المركزية عتبة معينة"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "Triggers when GPU usage exceeds a threshold"
|
||||||
|
msgstr "يتم التفعيل عندما يتجاوز استخدام وحدة معالجة الرسوميات عتبة معينة"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when memory usage exceeds a threshold"
|
msgid "Triggers when memory usage exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز استخدام الذاكرة عتبة معينة"
|
||||||
@@ -1168,6 +1533,16 @@ msgstr "يتم التفعيل عندما يتغير الحالة بين التش
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
msgstr "يتم التفعيل عندما يتجاوز استخدام أي قرص عتبة معينة"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "النوع"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unit file"
|
||||||
|
msgstr "ملف الوحدة"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1183,6 +1558,11 @@ msgstr "رمز مميز عالمي"
|
|||||||
msgid "Unknown"
|
msgid "Unknown"
|
||||||
msgstr "غير معروفة"
|
msgstr "غير معروفة"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unlimited"
|
||||||
|
msgstr "غير محدود"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -1193,10 +1573,20 @@ msgstr "قيد التشغيل"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "قيد التشغيل ({upSystemsLength})"
|
msgstr "قيد التشغيل ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Update"
|
||||||
|
msgstr "تحديث"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "تم التحديث"
|
msgstr "تم التحديث"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Updated every 10 minutes."
|
||||||
|
msgstr "يتم التحديث كل 10 دقائق."
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "رفع"
|
msgstr "رفع"
|
||||||
@@ -1209,6 +1599,7 @@ msgstr "مدة التشغيل"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "الاستخدام"
|
msgstr "الاستخدام"
|
||||||
|
|
||||||
@@ -1234,6 +1625,7 @@ msgstr "القيمة"
|
|||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "عرض"
|
msgstr "عرض"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "عرض المزيد"
|
msgstr "عرض المزيد"
|
||||||
@@ -1254,6 +1646,10 @@ msgstr "في انتظار وجود سجلات كافية للعرض"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
|
msgstr "هل تريد مساعدتنا في تحسين ترجماتنا؟ تحقق من <0>Crowdin</0> لمزيد من التفاصيل."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Wants"
|
||||||
|
msgstr "يريد"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Warning (%)"
|
msgid "Warning (%)"
|
||||||
msgstr "تحذير (%)"
|
msgstr "تحذير (%)"
|
||||||
@@ -1290,6 +1686,12 @@ msgstr "تكوين YAML"
|
|||||||
msgid "YAML Configuration"
|
msgid "YAML Configuration"
|
||||||
msgstr "تكوين YAML"
|
msgstr "تكوين YAML"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr "نعم"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
|
msgstr "تم تحديث إعدادات المستخدم الخاصة بك."
|
||||||
|
|||||||
@@ -8,30 +8,15 @@ msgstr ""
|
|||||||
"Language: bg\n"
|
"Language: bg\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-11-14 22:51\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Bulgarian\n"
|
"Language-Team: Bulgarian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: bg\n"
|
"X-Crowdin-Language: bg\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
|
||||||
msgstr "{0, plural, one {# ден} other {# дни}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 3600)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# hour} other {# hours}}"
|
|
||||||
msgstr "{0, plural, one {# час} other {# часа}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 60)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
|
|
||||||
msgstr "{0, plural, one {# минута} few {# минути} many {# минути} other {# минути}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -39,6 +24,18 @@ msgstr "{0, plural, one {# минута} few {# минути} many {# минут
|
|||||||
msgid "{0} of {1} row(s) selected."
|
msgid "{0} of {1} row(s) selected."
|
||||||
msgstr "{0} от {1} селектирани."
|
msgstr "{0} от {1} селектирани."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} day} other {{countString} days}}"
|
||||||
|
msgstr "{count, plural, one {{countString} ден} other {{countString} дни}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} hour} other {{countString} hours}}"
|
||||||
|
msgstr "{count, plural, one {{countString} час} other {{countString} часа}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||||
|
msgstr "{count, plural, one {{countString} минута} few {{countString} минути} many {{countString} минути} other {{countString} минути}}"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 час"
|
msgstr "1 час"
|
||||||
@@ -79,13 +76,16 @@ msgid "5 min"
|
|||||||
msgstr "5 минути"
|
msgstr "5 минути"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Действия"
|
msgstr "Действия"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Активен"
|
msgstr "Активен"
|
||||||
|
|
||||||
@@ -93,14 +93,20 @@ msgstr "Активен"
|
|||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Активни тревоги"
|
msgstr "Активни тревоги"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Active state"
|
||||||
|
msgstr "Активно състояние"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Add {foo}"
|
||||||
|
msgstr "Добави {foo}"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add <0>System</0>"
|
msgid "Add <0>System</0>"
|
||||||
msgstr "Добави <0>Система</0>"
|
msgstr "Добави <0>Система</0>"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
|
||||||
msgid "Add New System"
|
|
||||||
msgstr "Добави нова система"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add system"
|
msgid "Add system"
|
||||||
msgstr "Добави система"
|
msgstr "Добави система"
|
||||||
@@ -113,11 +119,19 @@ msgstr "Добави URL"
|
|||||||
msgid "Adjust display options for charts."
|
msgid "Adjust display options for charts."
|
||||||
msgstr "Настрой опциите за показване на диаграмите."
|
msgstr "Настрой опциите за показване на диаграмите."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Adjust the width of the main layout"
|
||||||
|
msgstr "Настройка ширината на основния макет"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Администратор"
|
msgstr "Администратор"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "After"
|
||||||
|
msgstr "След"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr "Агент"
|
msgstr "Агент"
|
||||||
@@ -142,6 +156,7 @@ msgstr "Всички контейнери"
|
|||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -203,6 +218,18 @@ msgstr "Bandwidth на мрежата"
|
|||||||
msgid "Battery"
|
msgid "Battery"
|
||||||
msgstr "Батерия"
|
msgstr "Батерия"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became active"
|
||||||
|
msgstr "Стана активен"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became inactive"
|
||||||
|
msgstr "Стана неактивен"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Before"
|
||||||
|
msgstr "Преди"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
||||||
msgstr "Beszel поддържа OpenID Connect и много други OAuth2 доставчици за удостоверяване."
|
msgstr "Beszel поддържа OpenID Connect и много други OAuth2 доставчици за удостоверяване."
|
||||||
@@ -220,6 +247,10 @@ msgstr "Двоичен код"
|
|||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr "Бита (Kbps, Mbps, Gbps)"
|
msgstr "Бита (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Boot state"
|
||||||
|
msgstr "Състояние при зареждане"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
@@ -229,11 +260,32 @@ msgstr "Байта (KB/s, MB/s, GB/s)"
|
|||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Кеш / Буфери"
|
msgstr "Кеш / Буфери"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can reload"
|
||||||
|
msgstr "Може да се презареди"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can start"
|
||||||
|
msgstr "Може да се стартира"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can stop"
|
||||||
|
msgstr "Може да се спре"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Откажи"
|
msgstr "Откажи"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Capabilities"
|
||||||
|
msgstr "Възможности"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Капацитет"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Внимание - възможност за загуба на данни"
|
msgstr "Внимание - възможност за загуба на данни"
|
||||||
@@ -275,10 +327,20 @@ msgstr "Провери log-овете за повече информация."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Провери услугата си за удостоверяване"
|
msgstr "Провери услугата си за удостоверяване"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Clear"
|
||||||
|
msgstr "Изчисти"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Click on a container to view more information."
|
msgid "Click on a container to view more information."
|
||||||
msgstr "Кликнете върху контейнер, за да видите повече информация."
|
msgstr "Кликнете върху контейнер, за да видите повече информация."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Кликнете върху устройство, за да видите повече информация."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "Кликнете върху система, за да видите повече информация."
|
msgstr "Кликнете върху система, за да видите повече информация."
|
||||||
@@ -301,6 +363,10 @@ msgstr "Настрой как получаваш нотификации за т
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Потвърди парола"
|
msgstr "Потвърди парола"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Conflicts"
|
||||||
|
msgstr "Конфликти"
|
||||||
|
|
||||||
#: src/components/active-alerts.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "Връзката е прекъсната"
|
msgstr "Връзката е прекъсната"
|
||||||
@@ -361,16 +427,38 @@ msgid "Copy YAML"
|
|||||||
msgstr "Копирай YAML"
|
msgstr "Копирай YAML"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "Процесор"
|
msgstr "Процесор"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Cores"
|
||||||
|
msgstr "CPU ядра"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "CPU Peak"
|
||||||
|
msgstr "Пик на CPU"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "CPU time"
|
||||||
|
msgstr "Време на CPU"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Time Breakdown"
|
||||||
|
msgstr "Разбивка на времето на CPU"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "Употреба на процесор"
|
msgstr "Употреба на процесор"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Create"
|
||||||
|
msgstr "Създай"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Създай акаунт"
|
msgstr "Създай акаунт"
|
||||||
@@ -397,15 +485,23 @@ msgstr "Кумулативно качване"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Текущо състояние"
|
msgstr "Текущо състояние"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#. Power Cycles
|
||||||
msgid "Dashboard"
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgstr "Табло"
|
msgid "Cycles"
|
||||||
|
msgstr "Цикли"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Daily"
|
||||||
|
msgstr "Дневно"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Времеви диапазон по подразбиране"
|
msgstr "Времеви диапазон по подразбиране"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Изтрий"
|
msgstr "Изтрий"
|
||||||
@@ -414,10 +510,18 @@ msgstr "Изтрий"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Изтрий пръстов отпечатък"
|
msgstr "Изтрий пръстов отпечатък"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Описание"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Detail"
|
msgid "Detail"
|
||||||
msgstr "Подробности"
|
msgstr "Подробности"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Устройство"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -458,6 +562,7 @@ msgid "Docker Network I/O"
|
|||||||
msgstr "Мрежов I/O използван от docker"
|
msgstr "Мрежов I/O използван от docker"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Documentation"
|
msgid "Documentation"
|
||||||
msgstr "Документация"
|
msgstr "Документация"
|
||||||
|
|
||||||
@@ -481,11 +586,16 @@ msgstr "Изтегляне"
|
|||||||
msgid "Duration"
|
msgid "Duration"
|
||||||
msgstr "Продължителност"
|
msgstr "Продължителност"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Редактирай"
|
msgstr "Редактирай"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Edit {foo}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
@@ -501,6 +611,11 @@ msgstr "Имейл нотификации"
|
|||||||
msgid "Empty"
|
msgid "Empty"
|
||||||
msgstr "Празна"
|
msgstr "Празна"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "End Time"
|
||||||
|
msgstr "Крайно време"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Enter email address to reset password"
|
msgid "Enter email address to reset password"
|
||||||
msgstr "Въведи имейл адрес за да нулираш паролата"
|
msgstr "Въведи имейл адрес за да нулираш паролата"
|
||||||
@@ -517,7 +632,10 @@ msgstr "Въведете Вашата еднократна парола."
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Грешка"
|
msgstr "Грешка"
|
||||||
|
|
||||||
@@ -528,10 +646,18 @@ msgstr "Грешка"
|
|||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}"
|
msgstr "Надвишава {0}{1} в последните {2, plural, one {# минута} other {# минути}}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exec main PID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Съществуващи системи които не са дефинирани в <0>config.yml</0> ще бъдат изтрити. Моля прави чести архиви."
|
msgstr "Съществуващи системи които не са дефинирани в <0>config.yml</0> ще бъдат изтрити. Моля прави чести архиви."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exited active"
|
||||||
|
msgstr "Излезе активно"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
msgstr "Експортиране"
|
msgstr "Експортиране"
|
||||||
@@ -548,12 +674,21 @@ msgstr "Експортирай конфигурацията на системи
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "Фаренхайт (°F)"
|
msgstr "Фаренхайт (°F)"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Failed"
|
||||||
|
msgstr "Неуспешно"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Неуспешни атрибути:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Неуспешно удостоверяване"
|
msgstr "Неуспешно удостоверяване"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Failed to save settings"
|
msgid "Failed to save settings"
|
||||||
msgstr "Неуспешно запазване на настройки"
|
msgstr "Неуспешно запазване на настройки"
|
||||||
|
|
||||||
@@ -565,9 +700,16 @@ msgstr "Неуспешно изпрати тестова нотификация"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Неуспешно обнови тревога"
|
msgstr "Неуспешно обнови тревога"
|
||||||
|
|
||||||
|
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Failed: {0}"
|
||||||
|
msgstr "Неуспешни: {0}"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Филтрирай..."
|
msgstr "Филтрирай..."
|
||||||
@@ -576,6 +718,10 @@ msgstr "Филтрирай..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "Пръстов отпечатък"
|
msgstr "Пръстов отпечатък"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr "Фърмуер"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "За <0>{min}</0> {min, plural, one {минута} other {минути}}"
|
msgstr "За <0>{min}</0> {min, plural, one {минута} other {минути}}"
|
||||||
@@ -601,6 +747,10 @@ msgstr "Пълна"
|
|||||||
msgid "General"
|
msgid "General"
|
||||||
msgstr "Общо"
|
msgstr "Общо"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Global"
|
||||||
|
msgstr "Глобален"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
msgstr "GPU двигатели"
|
msgstr "GPU двигатели"
|
||||||
@@ -609,6 +759,10 @@ msgstr "GPU двигатели"
|
|||||||
msgid "GPU Power Draw"
|
msgid "GPU Power Draw"
|
||||||
msgstr "Консумация на ток от графична карта"
|
msgstr "Консумация на ток от графична карта"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "GPU Usage"
|
||||||
|
msgstr "Употреба на GPU"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Мрежово"
|
msgstr "Мрежово"
|
||||||
@@ -641,6 +795,10 @@ msgctxt "Docker image"
|
|||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr "Образ"
|
msgstr "Образ"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "Неактивен"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Невалиден имейл адрес."
|
msgstr "Невалиден имейл адрес."
|
||||||
@@ -658,6 +816,19 @@ msgstr "Език"
|
|||||||
msgid "Layout"
|
msgid "Layout"
|
||||||
msgstr "Подреждане"
|
msgstr "Подреждане"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Layout width"
|
||||||
|
msgstr "Ширина на макета"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Lifecycle"
|
||||||
|
msgstr "Жизнен цикъл"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "limit"
|
||||||
|
msgstr "лимит"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Load Average"
|
msgid "Load Average"
|
||||||
msgstr "Средно натоварване"
|
msgstr "Средно натоварване"
|
||||||
@@ -679,6 +850,14 @@ msgstr "Средно натоварване 5 минути"
|
|||||||
msgid "Load Avg"
|
msgid "Load Avg"
|
||||||
msgstr "Средно натоварване"
|
msgstr "Средно натоварване"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Load state"
|
||||||
|
msgstr "Състояние на зареждане"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Loading..."
|
||||||
|
msgstr "Зареждане..."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "Изход"
|
msgstr "Изход"
|
||||||
@@ -702,6 +881,10 @@ msgstr "Логове"
|
|||||||
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
||||||
msgstr "Търсиш къде да създадеш тревоги? Натисни емотиконата за звънец <0/> в таблицата за системи."
|
msgstr "Търсиш къде да създадеш тревоги? Натисни емотиконата за звънец <0/> в таблицата за системи."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Main PID"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Manage display and notification preferences."
|
msgid "Manage display and notification preferences."
|
||||||
msgstr "Управление на предпочитанията за показване и уведомяване."
|
msgstr "Управление на предпочитанията за показване и уведомяване."
|
||||||
@@ -717,10 +900,21 @@ msgid "Max 1 min"
|
|||||||
msgstr "Максимум 1 минута"
|
msgstr "Максимум 1 минута"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Памет"
|
msgstr "Памет"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory limit"
|
||||||
|
msgstr "Лимит на памет"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory Peak"
|
||||||
|
msgstr "Пик на памет"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Memory Usage"
|
msgid "Memory Usage"
|
||||||
@@ -730,9 +924,15 @@ msgstr "Употреба на паметта"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Използването на памет от docker контейнерите"
|
msgstr "Използването на памет от docker контейнерите"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr "Модел"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Име"
|
msgstr "Име"
|
||||||
|
|
||||||
@@ -757,15 +957,30 @@ msgstr "Мрежов трафик на публични интерфейси"
|
|||||||
msgid "Network unit"
|
msgid "Network unit"
|
||||||
msgstr "Единица за измерване на скорост"
|
msgstr "Единица за измерване на скорост"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "No"
|
||||||
|
msgstr "Не"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Няма намерени резултати."
|
msgstr "Няма намерени резултати."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "Няма резултати."
|
msgstr "Няма резултати."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Няма налични S.M.A.R.T. атрибути за това устройство."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -785,12 +1000,19 @@ msgstr "Поддръжка на OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "На всеки рестарт, системите в датабазата ще бъдат обновени да съвпадат със системите зададени във файла."
|
msgstr "На всеки рестарт, системите в датабазата ще бъдат обновени да съвпадат със системите зададени във файла."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "One-time"
|
||||||
|
msgstr "Еднократен"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "One-time password"
|
msgid "One-time password"
|
||||||
msgstr "Еднократна парола"
|
msgstr "Еднократна парола"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
msgstr "Отвори менюто"
|
msgstr "Отвори менюто"
|
||||||
@@ -799,10 +1021,15 @@ msgstr "Отвори менюто"
|
|||||||
msgid "Or continue with"
|
msgid "Or continue with"
|
||||||
msgstr "Или продължи с"
|
msgstr "Или продължи с"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Other"
|
||||||
|
msgstr "Други"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Презапиши съществуващи тревоги"
|
msgstr "Презапиши съществуващи тревоги"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
@@ -835,6 +1062,10 @@ msgstr "Паролата трябва да е по-малка от 72 байта
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Получено е искането за нулиране на паролата"
|
msgstr "Получено е искането за нулиране на паролата"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Past"
|
||||||
|
msgstr "Минал"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Пауза"
|
msgstr "Пауза"
|
||||||
@@ -847,6 +1078,15 @@ msgstr "На пауза"
|
|||||||
msgid "Paused ({pausedSystemsLength})"
|
msgid "Paused ({pausedSystemsLength})"
|
||||||
msgstr "На пауза ({pausedSystemsLength})"
|
msgstr "На пауза ({pausedSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Per-core average utilization"
|
||||||
|
msgstr "Средно използване на ядро"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Percentage of time spent in each state"
|
||||||
|
msgstr "Процент време, прекарано във всяко състояние"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
msgstr "Моля <0>конфигурурай SMTP сървър</0> за да се подсигуриш, че тревогите са доставени."
|
msgstr "Моля <0>конфигурурай SMTP сървър</0> за да се подсигуриш, че тревогите са доставени."
|
||||||
@@ -884,6 +1124,11 @@ msgstr "Моля влез в акаунта ти"
|
|||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Порт"
|
msgstr "Порт"
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Включване"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Precise utilization at the recorded time"
|
msgid "Precise utilization at the recorded time"
|
||||||
@@ -893,11 +1138,19 @@ msgstr "Точно използване в записаното време"
|
|||||||
msgid "Preferred Language"
|
msgid "Preferred Language"
|
||||||
msgstr "Предпочитан език"
|
msgstr "Предпочитан език"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Process started"
|
||||||
|
msgstr "Процесът стартира"
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
#. Use 'Key' if your language requires many more characters
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Публичен ключ"
|
msgstr "Публичен ключ"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Quiet Hours"
|
||||||
|
msgstr "Тихи часове"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -910,9 +1163,14 @@ msgstr "Получени"
|
|||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Refresh"
|
msgid "Refresh"
|
||||||
msgstr "Опресни"
|
msgstr "Опресни"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Relationships"
|
||||||
|
msgstr "Връзки"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Заявка за еднократна парола"
|
msgstr "Заявка за еднократна парола"
|
||||||
@@ -921,6 +1179,14 @@ msgstr "Заявка за еднократна парола"
|
|||||||
msgid "Request OTP"
|
msgid "Request OTP"
|
||||||
msgstr "Заявка OTP"
|
msgstr "Заявка OTP"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Required by"
|
||||||
|
msgstr "Изисква се от"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Requires"
|
||||||
|
msgstr "Изисква"
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Нулиране на парола"
|
msgstr "Нулиране на парола"
|
||||||
@@ -931,10 +1197,19 @@ msgstr "Нулиране на парола"
|
|||||||
msgid "Resolved"
|
msgid "Resolved"
|
||||||
msgstr "Решен"
|
msgstr "Решен"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Restarts"
|
||||||
|
msgstr "Рестартирания"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Възобнови"
|
msgstr "Възобнови"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgctxt "Root disk label"
|
||||||
|
msgid "Root"
|
||||||
|
msgstr "Корен"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "Пресъздаване на идентификатора"
|
msgstr "Пресъздаване на идентификатора"
|
||||||
@@ -943,6 +1218,18 @@ msgstr "Пресъздаване на идентификатора"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "Редове на страница"
|
msgstr "Редове на страница"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Runtime Metrics"
|
||||||
|
msgstr "Метрики на изпълнение"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T. Детайли"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T. Самотест"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
|
||||||
@@ -956,6 +1243,18 @@ msgstr "Запази настройките"
|
|||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr "Запази система"
|
msgstr "Запази система"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule"
|
||||||
|
msgstr "График"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent, such as during maintenance periods."
|
||||||
|
msgstr "Планирай тихи часове, когато няма да се изпращат известия, като например по време на периоди на поддръжка."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent."
|
||||||
|
msgstr "Планирай тихи часове, когато няма да се изпращат известия."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Търси"
|
msgstr "Търси"
|
||||||
@@ -968,10 +1267,26 @@ msgstr "Търси за системи или настройки..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
|
msgstr "Виж <0>настройките за нотификациите</0> за да конфигурираш как получаваш тревоги."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Select {foo}"
|
||||||
|
msgstr "Избери {foo}"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Изпратени"
|
msgstr "Изпратени"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Сериен номер"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Service Details"
|
||||||
|
msgstr "Детайли на услугата"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Services"
|
||||||
|
msgstr "Услуги"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
|
msgstr "Задайте процентни прагове за цветовете на измервателните уреди."
|
||||||
@@ -999,17 +1314,30 @@ msgstr "Настройки за SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Сортиране по"
|
msgstr "Сортиране по"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Start Time"
|
||||||
|
msgstr "Начален час"
|
||||||
|
|
||||||
#. Context: alert state (active or resolved)
|
#. Context: alert state (active or resolved)
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "Състояние"
|
msgstr "Състояние"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Статус"
|
msgstr "Статус"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "Sub State"
|
||||||
|
msgstr "Подсъстояние"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Swap space used by the system"
|
msgid "Swap space used by the system"
|
||||||
msgstr "Изполван swap от системата"
|
msgstr "Изполван swap от системата"
|
||||||
@@ -1018,9 +1346,15 @@ msgstr "Изполван swap от системата"
|
|||||||
msgid "Swap Usage"
|
msgid "Swap Usage"
|
||||||
msgstr "Използване на swap"
|
msgstr "Използване на swap"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "System"
|
msgid "System"
|
||||||
@@ -1030,6 +1364,10 @@ msgstr "Система"
|
|||||||
msgid "System load averages over time"
|
msgid "System load averages over time"
|
||||||
msgstr "Средно натоварване на системата във времето"
|
msgstr "Средно натоварване на системата във времето"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Systemd Services"
|
||||||
|
msgstr "Услуги на systemd"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Системи"
|
msgstr "Системи"
|
||||||
@@ -1042,7 +1380,12 @@ msgstr "Системите могат да бъдат управлявани в
|
|||||||
msgid "Table"
|
msgid "Table"
|
||||||
msgstr "Таблица"
|
msgstr "Таблица"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Tasks"
|
||||||
|
msgstr "Задачи"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Температура"
|
msgstr "Температура"
|
||||||
@@ -1124,6 +1467,11 @@ msgstr "Токените позволяват на агентите да се с
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Токените и пръстовите отпечатъци се използват за удостоверяване на WebSocket връзките към концентратора."
|
msgstr "Токените и пръстовите отпечатъци се използват за удостоверяване на WebSocket връзките към концентратора."
|
||||||
|
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "Общо"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Общо получени данни за всеки интерфейс"
|
msgstr "Общо получени данни за всеки интерфейс"
|
||||||
@@ -1132,6 +1480,19 @@ msgstr "Общо получени данни за всеки интерфейс"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Общо изпратени данни за всеки интерфейс"
|
msgstr "Общо изпратени данни за всеки интерфейс"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.length
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Total: {0}"
|
||||||
|
msgstr "Общо: {0}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggered by"
|
||||||
|
msgstr "Активиран от"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggers"
|
||||||
|
msgstr "Активатори"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 1 minute load average exceeds a threshold"
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на паметта за 1 минута надвиши зададен праг"
|
msgstr "Задейства се, когато употребата на паметта за 1 минута надвиши зададен праг"
|
||||||
@@ -1156,6 +1517,10 @@ msgstr "Задейства се, когато комбинираното кач
|
|||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
msgid "Triggers when CPU usage exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на процесора надвиши зададен праг"
|
msgstr "Задейства се, когато употребата на процесора надвиши зададен праг"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "Triggers when GPU usage exceeds a threshold"
|
||||||
|
msgstr "Задейства се, когато използването на GPU надвиши праг"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when memory usage exceeds a threshold"
|
msgid "Triggers when memory usage exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на паметта надвиши зададен праг"
|
msgstr "Задейства се, когато употребата на паметта надвиши зададен праг"
|
||||||
@@ -1168,6 +1533,16 @@ msgstr "Задейства се, когато статуса превключв
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
msgstr "Задейства се, когато употребата на някой диск надивши зададен праг"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Тип"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unit file"
|
||||||
|
msgstr "Файл на единица"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1183,6 +1558,11 @@ msgstr "Универсален тоукън"
|
|||||||
msgid "Unknown"
|
msgid "Unknown"
|
||||||
msgstr "Неизвестна"
|
msgstr "Неизвестна"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unlimited"
|
||||||
|
msgstr "Неограничено"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -1193,10 +1573,20 @@ msgstr "Нагоре"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "Нагоре ({upSystemsLength})"
|
msgstr "Нагоре ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Update"
|
||||||
|
msgstr "Актуализирай"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Актуализирано"
|
msgstr "Актуализирано"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Updated every 10 minutes."
|
||||||
|
msgstr "Актуализира се на всеки 10 минути."
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Качване"
|
msgstr "Качване"
|
||||||
@@ -1209,6 +1599,7 @@ msgstr "Време на работа"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Употреба"
|
msgstr "Употреба"
|
||||||
|
|
||||||
@@ -1234,6 +1625,7 @@ msgstr "Стойност"
|
|||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Изглед"
|
msgstr "Изглед"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Виж повече"
|
msgstr "Виж повече"
|
||||||
@@ -1254,6 +1646,10 @@ msgstr "Изчаква се за достатъчно записи за пока
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Искаш да помогнеш да направиш преводите още по-добри? Провери нашия <0>Crowdin</0> за повече детайли."
|
msgstr "Искаш да помогнеш да направиш преводите още по-добри? Провери нашия <0>Crowdin</0> за повече детайли."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Wants"
|
||||||
|
msgstr "Иска"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Warning (%)"
|
msgid "Warning (%)"
|
||||||
msgstr "Предупреждение (%)"
|
msgstr "Предупреждение (%)"
|
||||||
@@ -1290,6 +1686,12 @@ msgstr "YAML конфигурация"
|
|||||||
msgid "YAML Configuration"
|
msgid "YAML Configuration"
|
||||||
msgstr "YAML конфигурация"
|
msgstr "YAML конфигурация"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr "Да"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Настройките за потребителя ти са обновени."
|
msgstr "Настройките за потребителя ти са обновени."
|
||||||
|
|||||||
@@ -8,30 +8,15 @@ msgstr ""
|
|||||||
"Language: cs\n"
|
"Language: cs\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2025-08-28 23:21\n"
|
"PO-Revision-Date: 2025-11-14 22:51\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Czech\n"
|
"Language-Team: Czech\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||||
"X-Crowdin-Project: beszel\n"
|
"X-Crowdin-Project: beszel\n"
|
||||||
"X-Crowdin-Project-ID: 733311\n"
|
"X-Crowdin-Project-ID: 733311\n"
|
||||||
"X-Crowdin-Language: cs\n"
|
"X-Crowdin-Language: cs\n"
|
||||||
"X-Crowdin-File: /main/beszel/site/src/locales/en/en.po\n"
|
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
|
||||||
"X-Crowdin-File-ID: 16\n"
|
"X-Crowdin-File-ID: 32\n"
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# day} other {# days}}"
|
|
||||||
msgstr "{0, plural, one {# den} few {# dny} other {# dní}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 3600)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# hour} other {# hours}}"
|
|
||||||
msgstr "{0, plural, one {# Hodina} few {# Hodiny} many {# Hodin} other {# Hodin}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: Math.trunc(system.info.u / 60)
|
|
||||||
#: src/components/routes/system.tsx
|
|
||||||
msgid "{0, plural, one {# minute} few {# minutes} many {# minutes} other {# minutes}}"
|
|
||||||
msgstr "{0, plural, one {# minuta} few {# minuty} many {# minut} other {# minut}}"
|
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -39,6 +24,18 @@ msgstr "{0, plural, one {# minuta} few {# minuty} many {# minut} other {# minut}
|
|||||||
msgid "{0} of {1} row(s) selected."
|
msgid "{0} of {1} row(s) selected."
|
||||||
msgstr "{0} z {1} vybraných řádků."
|
msgstr "{0} z {1} vybraných řádků."
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} day} other {{countString} days}}"
|
||||||
|
msgstr "{count, plural, one {{countString} den} few {{countString} dny} other {{countString} dní}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} hour} other {{countString} hours}}"
|
||||||
|
msgstr "{count, plural, one {{countString} Hodina} few {{countString} Hodiny} many {{countString} Hodin} other {{countString} Hodin}}"
|
||||||
|
|
||||||
|
#: src/lib/utils.ts
|
||||||
|
msgid "{count, plural, one {{countString} minute} few {{countString} minutes} many {{countString} minutes} other {{countString} minutes}}"
|
||||||
|
msgstr "{count, plural, one {{countString} minuta} few {{countString} minuty} many {{countString} minut} other {{countString} minut}}"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 hour"
|
msgid "1 hour"
|
||||||
msgstr "1 hodina"
|
msgstr "1 hodina"
|
||||||
@@ -79,13 +76,16 @@ msgid "5 min"
|
|||||||
msgstr "5 min"
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "Akce"
|
msgstr "Akce"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktivní"
|
msgstr "Aktivní"
|
||||||
|
|
||||||
@@ -93,14 +93,20 @@ msgstr "Aktivní"
|
|||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
msgstr "Aktivní výstrahy"
|
msgstr "Aktivní výstrahy"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Active state"
|
||||||
|
msgstr "Aktivní stav"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Add {foo}"
|
||||||
|
msgstr "Přidat {foo}"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add <0>System</0>"
|
msgid "Add <0>System</0>"
|
||||||
msgstr "Přidat <0>Systém</0>"
|
msgstr "Přidat <0>Systém</0>"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
|
||||||
msgid "Add New System"
|
|
||||||
msgstr "Přidat nový systém"
|
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Add system"
|
msgid "Add system"
|
||||||
msgstr "Přidat systém"
|
msgstr "Přidat systém"
|
||||||
@@ -113,10 +119,18 @@ msgstr "Přidat URL"
|
|||||||
msgid "Adjust display options for charts."
|
msgid "Adjust display options for charts."
|
||||||
msgstr "Upravit možnosti zobrazení pro grafy."
|
msgstr "Upravit možnosti zobrazení pro grafy."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Adjust the width of the main layout"
|
||||||
|
msgstr "Upravit šířku hlavního rozvržení"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr "Admin"
|
msgstr "Administrátor"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "After"
|
||||||
|
msgstr "Po"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
@@ -142,6 +156,7 @@ msgstr "Všechny kontejnery"
|
|||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/home.tsx
|
#: src/components/routes/home.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "All Systems"
|
msgid "All Systems"
|
||||||
@@ -203,6 +218,18 @@ msgstr "Přenos"
|
|||||||
msgid "Battery"
|
msgid "Battery"
|
||||||
msgstr "Baterie"
|
msgstr "Baterie"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became active"
|
||||||
|
msgstr "Stal se aktivním"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Became inactive"
|
||||||
|
msgstr "Stal se neaktivním"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Before"
|
||||||
|
msgstr "Před"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
||||||
msgstr "Beszel podporuje OpenID Connect a mnoho poskytovatelů OAuth2 ověřování."
|
msgstr "Beszel podporuje OpenID Connect a mnoho poskytovatelů OAuth2 ověřování."
|
||||||
@@ -213,27 +240,52 @@ msgstr "Beszel používá <0>Shoutrrr</0> k integraci s populárními notifikač
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Binary"
|
msgid "Binary"
|
||||||
msgstr "Binary"
|
msgstr "Binární"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr "Bits (Kbps, Mbps, Gbps)"
|
msgstr "Bity (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Boot state"
|
||||||
|
msgstr "Stav zavádění"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
msgstr "Byty (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/charts/mem-chart.tsx
|
#: src/components/charts/mem-chart.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
msgstr "Cache / vyrovnávací paměť"
|
msgstr "Cache / vyrovnávací paměť"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can reload"
|
||||||
|
msgstr "Může znovu načíst"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can start"
|
||||||
|
msgstr "Může spustit"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Can stop"
|
||||||
|
msgstr "Může zastavit"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Zrušit"
|
msgstr "Zrušit"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Capabilities"
|
||||||
|
msgstr "Schopnosti"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Capacity"
|
||||||
|
msgstr "Kapacita"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Caution - potential data loss"
|
msgid "Caution - potential data loss"
|
||||||
msgstr "Upozornění - možná ztráta dat"
|
msgstr "Upozornění - možná ztráta dat"
|
||||||
@@ -275,10 +327,20 @@ msgstr "Pro více informací zkontrolujte logy."
|
|||||||
msgid "Check your notification service"
|
msgid "Check your notification service"
|
||||||
msgstr "Zkontrolujte službu upozornění"
|
msgstr "Zkontrolujte službu upozornění"
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systems-table/systems-table.tsx
|
||||||
|
msgid "Clear"
|
||||||
|
msgstr "Vymazat"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Click on a container to view more information."
|
msgid "Click on a container to view more information."
|
||||||
msgstr "Klikněte na kontejner pro zobrazení dalších informací."
|
msgstr "Klikněte na kontejner pro zobrazení dalších informací."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Click on a device to view more information."
|
||||||
|
msgstr "Klikněte na zařízení pro zobrazení dalších informací."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Click on a system to view more information."
|
msgid "Click on a system to view more information."
|
||||||
msgstr "Klikněte na systém pro zobrazení více informací."
|
msgstr "Klikněte na systém pro zobrazení více informací."
|
||||||
@@ -301,6 +363,10 @@ msgstr "Konfigurace způsobu přijímání upozornění."
|
|||||||
msgid "Confirm password"
|
msgid "Confirm password"
|
||||||
msgstr "Potvrdit heslo"
|
msgstr "Potvrdit heslo"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Conflicts"
|
||||||
|
msgstr "Konflikty"
|
||||||
|
|
||||||
#: src/components/active-alerts.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Connection is down"
|
msgid "Connection is down"
|
||||||
msgstr "Připojení je nedostupné"
|
msgstr "Připojení je nedostupné"
|
||||||
@@ -361,16 +427,38 @@ msgid "Copy YAML"
|
|||||||
msgstr "Kopírovat YAML"
|
msgstr "Kopírovat YAML"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "Procesor"
|
msgstr "Procesor"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Cores"
|
||||||
|
msgstr "CPU jádra"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "CPU Peak"
|
||||||
|
msgstr "Špička CPU"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "CPU time"
|
||||||
|
msgstr "Čas CPU"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "CPU Time Breakdown"
|
||||||
|
msgstr "Rozdělení času CPU"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "CPU Usage"
|
msgid "CPU Usage"
|
||||||
msgstr "Využití procesoru"
|
msgstr "Využití procesoru"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Create"
|
||||||
|
msgstr "Vytvořit"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Create account"
|
msgid "Create account"
|
||||||
msgstr "Vytvořit účet"
|
msgstr "Vytvořit účet"
|
||||||
@@ -397,15 +485,23 @@ msgstr "Kumulativní odeslání"
|
|||||||
msgid "Current state"
|
msgid "Current state"
|
||||||
msgstr "Aktuální stav"
|
msgstr "Aktuální stav"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#. Power Cycles
|
||||||
msgid "Dashboard"
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgstr "Přehled"
|
msgid "Cycles"
|
||||||
|
msgstr "Cykly"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Daily"
|
||||||
|
msgstr "Denně"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
msgstr "Výchozí doba"
|
msgstr "Výchozí doba"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "Odstranit"
|
msgstr "Odstranit"
|
||||||
@@ -414,10 +510,18 @@ msgstr "Odstranit"
|
|||||||
msgid "Delete fingerprint"
|
msgid "Delete fingerprint"
|
||||||
msgstr "Smazat identifikátor"
|
msgstr "Smazat identifikátor"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Popis"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Detail"
|
msgid "Detail"
|
||||||
msgstr "Detail"
|
msgstr "Detail"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Device"
|
||||||
|
msgstr "Zařízení"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Discharging"
|
msgid "Discharging"
|
||||||
@@ -425,11 +529,11 @@ msgstr "Vybíjení"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr "Disk"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr "Disk I/O"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Disk unit"
|
msgid "Disk unit"
|
||||||
@@ -458,6 +562,7 @@ msgid "Docker Network I/O"
|
|||||||
msgstr "Síťové I/O Dockeru"
|
msgstr "Síťové I/O Dockeru"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Documentation"
|
msgid "Documentation"
|
||||||
msgstr "Dokumentace"
|
msgstr "Dokumentace"
|
||||||
|
|
||||||
@@ -481,16 +586,21 @@ msgstr "Stažení"
|
|||||||
msgid "Duration"
|
msgid "Duration"
|
||||||
msgstr "Doba trvání"
|
msgstr "Doba trvání"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Edit"
|
msgid "Edit"
|
||||||
msgstr "Upravit"
|
msgstr "Upravit"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Edit {foo}"
|
||||||
|
msgstr "Upravit {foo}"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "Email"
|
msgstr "E-mail"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Email notifications"
|
msgid "Email notifications"
|
||||||
@@ -501,6 +611,11 @@ msgstr "Emailová upozornění"
|
|||||||
msgid "Empty"
|
msgid "Empty"
|
||||||
msgstr "Prázdná"
|
msgstr "Prázdná"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "End Time"
|
||||||
|
msgstr "Čas ukončení"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Enter email address to reset password"
|
msgid "Enter email address to reset password"
|
||||||
msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
|
msgstr "Zadejte e-mailovou adresu pro obnovu hesla"
|
||||||
@@ -517,7 +632,10 @@ msgstr "Zadejte Vaše jednorázové heslo."
|
|||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Error"
|
msgid "Error"
|
||||||
msgstr "Chyba"
|
msgstr "Chyba"
|
||||||
|
|
||||||
@@ -528,13 +646,21 @@ msgstr "Chyba"
|
|||||||
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
|
msgstr "Překračuje {0}{1} za {2, plural, one {poslední # minutu} few {poslední # minuty} other {posledních # minut}}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exec main PID"
|
||||||
|
msgstr "Hlavní PID spuštění"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
msgid "Existing systems not defined in <0>config.yml</0> will be deleted. Please make regular backups."
|
||||||
msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, budou odstraněny. Provádějte pravidelné zálohování."
|
msgstr "Stávající systémy, které nejsou definovány v <0>config.yml</0>, budou odstraněny. Provádějte pravidelné zálohování."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Exited active"
|
||||||
|
msgstr "Ukončeno aktivně"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
msgstr "Export"
|
msgstr "Exportovat"
|
||||||
|
|
||||||
#: src/components/routes/settings/config-yaml.tsx
|
#: src/components/routes/settings/config-yaml.tsx
|
||||||
msgid "Export configuration"
|
msgid "Export configuration"
|
||||||
@@ -548,12 +674,21 @@ msgstr "Exportovat aktuální konfiguraci systémů."
|
|||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr "Fahrenheita (°F)"
|
msgstr "Fahrenheita (°F)"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Failed"
|
||||||
|
msgstr "Selhalo"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Failed Attributes:"
|
||||||
|
msgstr "Neúspěšné atributy:"
|
||||||
|
|
||||||
#: src/lib/api.ts
|
#: src/lib/api.ts
|
||||||
msgid "Failed to authenticate"
|
msgid "Failed to authenticate"
|
||||||
msgstr "Ověření se nezdařilo"
|
msgstr "Ověření se nezdařilo"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Failed to save settings"
|
msgid "Failed to save settings"
|
||||||
msgstr "Nepodařilo se uložit nastavení"
|
msgstr "Nepodařilo se uložit nastavení"
|
||||||
|
|
||||||
@@ -565,9 +700,16 @@ msgstr "Nepodařilo se odeslat testovací oznámení"
|
|||||||
msgid "Failed to update alert"
|
msgid "Failed to update alert"
|
||||||
msgstr "Nepodařilo se aktualizovat upozornění"
|
msgstr "Nepodařilo se aktualizovat upozornění"
|
||||||
|
|
||||||
|
#. placeholder {0}: statusTotals[ServiceStatus.Failed]
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Failed: {0}"
|
||||||
|
msgstr "Neúspěšné: {0}"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr "Filtr..."
|
msgstr "Filtr..."
|
||||||
@@ -576,6 +718,10 @@ msgstr "Filtr..."
|
|||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
msgstr "Otisk"
|
msgstr "Otisk"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Firmware"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
|
msgstr "Za <0>{min}</0> {min, plural, one {minutu} few {minuty} other {minut}}"
|
||||||
@@ -601,6 +747,10 @@ msgstr "Plná"
|
|||||||
msgid "General"
|
msgid "General"
|
||||||
msgstr "Obecné"
|
msgstr "Obecné"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Global"
|
||||||
|
msgstr "Globální"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
msgstr "GPU enginy"
|
msgstr "GPU enginy"
|
||||||
@@ -609,6 +759,10 @@ msgstr "GPU enginy"
|
|||||||
msgid "GPU Power Draw"
|
msgid "GPU Power Draw"
|
||||||
msgstr "Spotřeba energie GPU"
|
msgstr "Spotřeba energie GPU"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "GPU Usage"
|
||||||
|
msgstr "Využití GPU"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Grid"
|
msgid "Grid"
|
||||||
msgstr "Mřížka"
|
msgstr "Mřížka"
|
||||||
@@ -641,6 +795,10 @@ msgctxt "Docker image"
|
|||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr "Obraz"
|
msgstr "Obraz"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Inactive"
|
||||||
|
msgstr "Neaktivní"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Invalid email address."
|
msgid "Invalid email address."
|
||||||
msgstr "Neplatná e-mailová adresa."
|
msgstr "Neplatná e-mailová adresa."
|
||||||
@@ -648,7 +806,7 @@ msgstr "Neplatná e-mailová adresa."
|
|||||||
#. Linux kernel
|
#. Linux kernel
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Kernel"
|
msgid "Kernel"
|
||||||
msgstr "Kernel"
|
msgstr "Jádro"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Language"
|
msgid "Language"
|
||||||
@@ -658,6 +816,19 @@ msgstr "Jazyk"
|
|||||||
msgid "Layout"
|
msgid "Layout"
|
||||||
msgstr "Rozvržení"
|
msgstr "Rozvržení"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/general.tsx
|
||||||
|
msgid "Layout width"
|
||||||
|
msgstr "Šířka rozvržení"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Lifecycle"
|
||||||
|
msgstr "Životní cyklus"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "limit"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Load Average"
|
msgid "Load Average"
|
||||||
msgstr "Průměrné vytížení"
|
msgstr "Průměrné vytížení"
|
||||||
@@ -679,6 +850,14 @@ msgstr "Průměrná zátěž 5m"
|
|||||||
msgid "Load Avg"
|
msgid "Load Avg"
|
||||||
msgstr "Prům. zatížení"
|
msgstr "Prům. zatížení"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Load state"
|
||||||
|
msgstr "Stav načtení"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Loading..."
|
||||||
|
msgstr "Načítání..."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr "Odhlásit"
|
msgstr "Odhlásit"
|
||||||
@@ -702,6 +881,10 @@ msgstr "Logy"
|
|||||||
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
|
||||||
msgstr "Hledáte místo kde vytvářet upozornění? Klikněte na ikonu zvonku <0/> v systémové tabulce."
|
msgstr "Hledáte místo kde vytvářet upozornění? Klikněte na ikonu zvonku <0/> v systémové tabulce."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Main PID"
|
||||||
|
msgstr "Hlavní PID"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Manage display and notification preferences."
|
msgid "Manage display and notification preferences."
|
||||||
msgstr "Správa nastavení zobrazení a oznámení."
|
msgstr "Správa nastavení zobrazení a oznámení."
|
||||||
@@ -717,10 +900,21 @@ msgid "Max 1 min"
|
|||||||
msgstr "Max. 1 min"
|
msgstr "Max. 1 min"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Memory"
|
msgid "Memory"
|
||||||
msgstr "Paměť"
|
msgstr "Paměť"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory limit"
|
||||||
|
msgstr "Limit paměti"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Memory Peak"
|
||||||
|
msgstr "Špička paměti"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Memory Usage"
|
msgid "Memory Usage"
|
||||||
@@ -730,9 +924,15 @@ msgstr "Využití paměti"
|
|||||||
msgid "Memory usage of docker containers"
|
msgid "Memory usage of docker containers"
|
||||||
msgstr "Využití paměti docker kontejnerů"
|
msgstr "Využití paměti docker kontejnerů"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Model"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Název"
|
msgstr "Název"
|
||||||
|
|
||||||
@@ -757,15 +957,30 @@ msgstr "Síťový provoz veřejných rozhraní"
|
|||||||
msgid "Network unit"
|
msgid "Network unit"
|
||||||
msgstr "Síťová jednotka"
|
msgstr "Síťová jednotka"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "No"
|
||||||
|
msgstr "Ne"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results found."
|
msgid "No results found."
|
||||||
msgstr "Nenalezeny žádné výskyty."
|
msgstr "Nenalezeny žádné výskyty."
|
||||||
|
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No results."
|
msgid "No results."
|
||||||
msgstr "Žádné výsledky."
|
msgstr "Žádné výsledky."
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "No S.M.A.R.T. attributes available for this device."
|
||||||
|
msgstr "Pro toto zařízení nejsou k dispozici žádné atributy S.M.A.R.T."
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "No systems found."
|
msgid "No systems found."
|
||||||
@@ -785,12 +1000,19 @@ msgstr "Podpora OAuth 2 / OIDC"
|
|||||||
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
|
||||||
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
|
msgstr "Při každém restartu budou systémy v databázi aktualizovány tak, aby odpovídaly systémům definovaným v souboru."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "One-time"
|
||||||
|
msgstr "Jednorázové"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "One-time password"
|
msgid "One-time password"
|
||||||
msgstr "Jednorázové heslo"
|
msgstr "Jednorázové heslo"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Open menu"
|
msgid "Open menu"
|
||||||
msgstr "Otevřít menu"
|
msgstr "Otevřít menu"
|
||||||
@@ -799,10 +1021,15 @@ msgstr "Otevřít menu"
|
|||||||
msgid "Or continue with"
|
msgid "Or continue with"
|
||||||
msgstr "Nebo pokračujte s"
|
msgstr "Nebo pokračujte s"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Other"
|
||||||
|
msgstr "Jiné"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "Overwrite existing alerts"
|
msgid "Overwrite existing alerts"
|
||||||
msgstr "Přepsat existující upozornění"
|
msgstr "Přepsat existující upozornění"
|
||||||
|
|
||||||
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
@@ -835,6 +1062,10 @@ msgstr "Heslo musí být menší než 72 bytů."
|
|||||||
msgid "Password reset request received"
|
msgid "Password reset request received"
|
||||||
msgstr "Žádost o obnovu hesla byla přijata"
|
msgstr "Žádost o obnovu hesla byla přijata"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Past"
|
||||||
|
msgstr "Minulé"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr "Pozastavit"
|
msgstr "Pozastavit"
|
||||||
@@ -847,6 +1078,15 @@ msgstr "Pozastaveno"
|
|||||||
msgid "Paused ({pausedSystemsLength})"
|
msgid "Paused ({pausedSystemsLength})"
|
||||||
msgstr "Pozastaveno ({pausedSystemsLength})"
|
msgstr "Pozastaveno ({pausedSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Per-core average utilization"
|
||||||
|
msgstr "Průměrné využití na jádro"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
msgid "Percentage of time spent in each state"
|
||||||
|
msgstr "Procento času strávěného v každém stavu"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
|
||||||
msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena."
|
msgstr "<0>nakonfigurujte SMTP server</0> pro zajištění toho, aby byla upozornění doručena."
|
||||||
@@ -882,7 +1122,12 @@ msgstr "Přihlaste se prosím k vašemu účtu"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr "Port"
|
msgstr ""
|
||||||
|
|
||||||
|
#. Power On Time
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Power On"
|
||||||
|
msgstr "Zapnutí"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -893,11 +1138,19 @@ msgstr "Přesné využití v zaznamenaném čase"
|
|||||||
msgid "Preferred Language"
|
msgid "Preferred Language"
|
||||||
msgstr "Upřednostňovaný jazyk"
|
msgstr "Upřednostňovaný jazyk"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Process started"
|
||||||
|
msgstr "Proces spuštěn"
|
||||||
|
|
||||||
#. Use 'Key' if your language requires many more characters
|
#. Use 'Key' if your language requires many more characters
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Veřejný klíč"
|
msgstr "Veřejný klíč"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Quiet Hours"
|
||||||
|
msgstr "Tiché hodiny"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
@@ -910,9 +1163,14 @@ msgstr "Přijato"
|
|||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Refresh"
|
msgid "Refresh"
|
||||||
msgstr "Aktualizovat"
|
msgstr "Aktualizovat"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Relationships"
|
||||||
|
msgstr "Vztahy"
|
||||||
|
|
||||||
#: src/components/login/login.tsx
|
#: src/components/login/login.tsx
|
||||||
msgid "Request a one-time password"
|
msgid "Request a one-time password"
|
||||||
msgstr "Požádat o jednorázové heslo"
|
msgstr "Požádat o jednorázové heslo"
|
||||||
@@ -921,6 +1179,14 @@ msgstr "Požádat o jednorázové heslo"
|
|||||||
msgid "Request OTP"
|
msgid "Request OTP"
|
||||||
msgstr "Požádat OTP"
|
msgstr "Požádat OTP"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Required by"
|
||||||
|
msgstr "Vyžadováno službou"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Requires"
|
||||||
|
msgstr "Vyžaduje"
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Reset Password"
|
msgid "Reset Password"
|
||||||
msgstr "Obnovit heslo"
|
msgstr "Obnovit heslo"
|
||||||
@@ -931,10 +1197,19 @@ msgstr "Obnovit heslo"
|
|||||||
msgid "Resolved"
|
msgid "Resolved"
|
||||||
msgstr "Vyřešeno"
|
msgstr "Vyřešeno"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Restarts"
|
||||||
|
msgstr "Restarty"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Resume"
|
msgid "Resume"
|
||||||
msgstr "Pokračovat"
|
msgstr "Pokračovat"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgctxt "Root disk label"
|
||||||
|
msgid "Root"
|
||||||
|
msgstr "Kořenový"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
msgstr "Změnit token"
|
msgstr "Změnit token"
|
||||||
@@ -943,6 +1218,18 @@ msgstr "Změnit token"
|
|||||||
msgid "Rows per page"
|
msgid "Rows per page"
|
||||||
msgstr "Řádků na stránku"
|
msgstr "Řádků na stránku"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Runtime Metrics"
|
||||||
|
msgstr "Metriky běhu"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Details"
|
||||||
|
msgstr "S.M.A.R.T. Detaily"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "S.M.A.R.T. Self-Test"
|
||||||
|
msgstr "S.M.A.R.T. Vlastní test"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
|
||||||
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
|
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
|
||||||
@@ -956,6 +1243,18 @@ msgstr "Uložit nastavení"
|
|||||||
msgid "Save system"
|
msgid "Save system"
|
||||||
msgstr "Uložit systém"
|
msgstr "Uložit systém"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule"
|
||||||
|
msgstr "Plán"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent, such as during maintenance periods."
|
||||||
|
msgstr "Naplánujte tiché hodiny, kdy se nebudou odesílat oznámení, například během období údržby."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Schedule quiet hours where notifications will not be sent."
|
||||||
|
msgstr "Naplánujte tiché hodiny, kdy se nebudou odesílat oznámení."
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "Hledat"
|
msgstr "Hledat"
|
||||||
@@ -968,10 +1267,26 @@ msgstr "Hledat systémy nebo nastavení..."
|
|||||||
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
msgid "See <0>notification settings</0> to configure how you receive alerts."
|
||||||
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
|
msgstr "Podívejte se na <0>nastavení upozornění</0> pro nastavení toho, jak přijímáte upozornění."
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Select {foo}"
|
||||||
|
msgstr "Vybrat {foo}"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
msgstr "Odeslat"
|
msgstr "Odeslat"
|
||||||
|
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Serial Number"
|
||||||
|
msgstr "Sériové číslo"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Service Details"
|
||||||
|
msgstr "Detaily služby"
|
||||||
|
|
||||||
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
|
msgid "Services"
|
||||||
|
msgstr "Služby"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
|
msgstr "Nastavte procentuální prahové hodnoty pro barvy měřičů."
|
||||||
@@ -999,17 +1314,30 @@ msgstr "Nastavení SMTP"
|
|||||||
msgid "Sort By"
|
msgid "Sort By"
|
||||||
msgstr "Seřadit podle"
|
msgstr "Seřadit podle"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Start Time"
|
||||||
|
msgstr "Čas začátku"
|
||||||
|
|
||||||
#. Context: alert state (active or resolved)
|
#. Context: alert state (active or resolved)
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "State"
|
msgid "State"
|
||||||
msgstr "Stav"
|
msgstr "Stav"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr "Stav"
|
msgstr "Stav"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
|
msgid "Sub State"
|
||||||
|
msgstr "Podstav"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "Swap space used by the system"
|
msgid "Swap space used by the system"
|
||||||
msgstr "Swap prostor využívaný systémem"
|
msgstr "Swap prostor využívaný systémem"
|
||||||
@@ -1018,9 +1346,15 @@ msgstr "Swap prostor využívaný systémem"
|
|||||||
msgid "Swap Usage"
|
msgid "Swap Usage"
|
||||||
msgstr "Swap využití"
|
msgstr "Swap využití"
|
||||||
|
|
||||||
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "System"
|
msgid "System"
|
||||||
@@ -1030,6 +1364,10 @@ msgstr "Systém"
|
|||||||
msgid "System load averages over time"
|
msgid "System load averages over time"
|
||||||
msgstr "Průměry zatížení systému v průběhu času"
|
msgstr "Průměry zatížení systému v průběhu času"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Systemd Services"
|
||||||
|
msgstr "Služby systemd"
|
||||||
|
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Systems"
|
msgid "Systems"
|
||||||
msgstr "Systémy"
|
msgstr "Systémy"
|
||||||
@@ -1042,7 +1380,12 @@ msgstr "Systémy lze spravovat v souboru <0>config.yml</0> uvnitř datového adr
|
|||||||
msgid "Table"
|
msgid "Table"
|
||||||
msgstr "Tabulka"
|
msgstr "Tabulka"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Tasks"
|
||||||
|
msgstr "Úlohy"
|
||||||
|
|
||||||
#. Temperature label in systems table
|
#. Temperature label in systems table
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr "Teplota"
|
msgstr "Teplota"
|
||||||
@@ -1062,7 +1405,7 @@ msgstr "Teploty systémových senzorů"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test <0>URL</0>"
|
msgid "Test <0>URL</0>"
|
||||||
msgstr "Test <0>URL</0>"
|
msgstr "Testovat <0>URL</0>"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test notification sent"
|
msgid "Test notification sent"
|
||||||
@@ -1108,7 +1451,7 @@ msgstr "Přepnout motiv"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr "Token"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1124,6 +1467,11 @@ msgstr "Tokeny umožňují agentům připojení a registraci. Otisky jsou stabil
|
|||||||
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
msgid "Tokens and fingerprints are used to authenticate WebSocket connections to the hub."
|
||||||
msgstr "Tokeny a otisky slouží k ověření připojení WebSocket k uzlu."
|
msgstr "Tokeny a otisky slouží k ověření připojení WebSocket k uzlu."
|
||||||
|
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
#: src/components/ui/chart.tsx
|
||||||
|
msgid "Total"
|
||||||
|
msgstr "Celkem"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
||||||
@@ -1132,6 +1480,19 @@ msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Celkový odeslaný objem dat pro každé rozhraní"
|
msgstr "Celkový odeslaný objem dat pro každé rozhraní"
|
||||||
|
|
||||||
|
#. placeholder {0}: data.length
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Total: {0}"
|
||||||
|
msgstr "Celkem: {0}"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggered by"
|
||||||
|
msgstr "Spuštěno službou"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Triggers"
|
||||||
|
msgstr "Spouštěče"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when 1 minute load average exceeds a threshold"
|
msgid "Triggers when 1 minute load average exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití paměti během 1 minuty překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití paměti během 1 minuty překročí prahovou hodnotu"
|
||||||
@@ -1156,6 +1517,10 @@ msgstr "Spustí se, když kombinace up/down překročí prahovou hodnotu"
|
|||||||
msgid "Triggers when CPU usage exceeds a threshold"
|
msgid "Triggers when CPU usage exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití procesoru překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití procesoru překročí prahovou hodnotu"
|
||||||
|
|
||||||
|
#: src/lib/alerts.ts
|
||||||
|
msgid "Triggers when GPU usage exceeds a threshold"
|
||||||
|
msgstr "Spustí se, když využití GPU překročí prahovou hodnotu"
|
||||||
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Triggers when memory usage exceeds a threshold"
|
msgid "Triggers when memory usage exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití paměti překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití paměti překročí prahovou hodnotu"
|
||||||
@@ -1168,6 +1533,16 @@ msgstr "Spouští se, když se změní dostupnost"
|
|||||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||||
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
msgstr "Spustí se, když využití disku překročí prahovou hodnotu"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
msgid "Type"
|
||||||
|
msgstr "Typ"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unit file"
|
||||||
|
msgstr "Soubor jednotky"
|
||||||
|
|
||||||
#. Temperature / network units
|
#. Temperature / network units
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Unit preferences"
|
msgid "Unit preferences"
|
||||||
@@ -1183,6 +1558,11 @@ msgstr "Univerzální token"
|
|||||||
msgid "Unknown"
|
msgid "Unknown"
|
||||||
msgstr "Neznámá"
|
msgstr "Neznámá"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Unlimited"
|
||||||
|
msgstr "Neomezeno"
|
||||||
|
|
||||||
#. Context: System is up
|
#. Context: System is up
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
@@ -1193,10 +1573,20 @@ msgstr "Funkční"
|
|||||||
msgid "Up ({upSystemsLength})"
|
msgid "Up ({upSystemsLength})"
|
||||||
msgstr "Funkční ({upSystemsLength})"
|
msgstr "Funkční ({upSystemsLength})"
|
||||||
|
|
||||||
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
|
msgid "Update"
|
||||||
|
msgstr "Aktualizovat"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
|
#: src/components/routes/system/smart-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "Aktualizováno"
|
msgstr "Aktualizováno"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Updated every 10 minutes."
|
||||||
|
msgstr "Aktualizováno každých 10 minut."
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Upload"
|
msgid "Upload"
|
||||||
msgstr "Odeslání"
|
msgstr "Odeslání"
|
||||||
@@ -1209,6 +1599,7 @@ msgstr "Doba provozu"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Usage"
|
msgid "Usage"
|
||||||
msgstr "Využití"
|
msgstr "Využití"
|
||||||
|
|
||||||
@@ -1234,6 +1625,7 @@ msgstr "Hodnota"
|
|||||||
msgid "View"
|
msgid "View"
|
||||||
msgstr "Zobrazení"
|
msgstr "Zobrazení"
|
||||||
|
|
||||||
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Zobrazit více"
|
msgstr "Zobrazit více"
|
||||||
@@ -1254,6 +1646,10 @@ msgstr "Čeká se na dostatek záznamů k zobrazení"
|
|||||||
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
msgid "Want to help improve our translations? Check <0>Crowdin</0> for details."
|
||||||
msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <0>Crowdin</0> pro více informací."
|
msgstr "Chcete nám pomoci s našimi překlady ještě lépe? Podívejte se na <0>Crowdin</0> pro více informací."
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Wants"
|
||||||
|
msgstr "Chce"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Warning (%)"
|
msgid "Warning (%)"
|
||||||
msgstr "Varování (%)"
|
msgstr "Varování (%)"
|
||||||
@@ -1290,6 +1686,12 @@ msgstr "YAML konfigurace"
|
|||||||
msgid "YAML Configuration"
|
msgid "YAML Configuration"
|
||||||
msgstr "YAML konfigurace"
|
msgstr "YAML konfigurace"
|
||||||
|
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
|
msgid "Yes"
|
||||||
|
msgstr "Ano"
|
||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Vaše uživatelská nastavení byla aktualizována."
|
msgstr "Vaše uživatelská nastavení byla aktualizována."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user