Compare commits

..

1 Commits

Author SHA1 Message Date
henrygd
fbbdd49fc2 collect top process 2025-11-03 21:50:33 -05:00
144 changed files with 5244 additions and 20685 deletions

View File

@@ -10,7 +10,6 @@ jobs:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
include:
# henrygd/beszel
@@ -25,18 +24,19 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent:alpine
# henrygd/beszel-agent
- image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
dockerfile: ./internal/dockerfile_agent
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
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent-nvidia
- image: henrygd/beszel-agent-nvidia
@@ -66,6 +66,18 @@ jobs:
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
# henrygd/beszel-agent:alpine
- image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# ghcr.io/henrygd/beszel
- image: ghcr.io/${{ github.repository }}/beszel
dockerfile: ./internal/dockerfile_hub
@@ -87,7 +99,6 @@ jobs:
password_secret: GITHUB_TOKEN
tags: |
type=raw,value=edge
type=raw,value=latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -133,19 +144,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
- image: henrygd/beszel-agent
dockerfile: ./internal/dockerfile_agent
registry: docker.io
username_secret: DOCKERHUB_USERNAME
password_secret: DOCKERHUB_TOKEN
tags: |
type=raw,value=edge
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
permissions:
contents: read
packages: write

View File

@@ -10,25 +10,12 @@ permissions:
pull-requests: write
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:
name: Close Stale Issues
runs-on: ubuntu-24.04
steps:
- name: Close Stale Issues
uses: actions/stale@v10
uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
@@ -45,8 +32,6 @@ jobs:
# Timing
days-before-issue-stale: 14
days-before-issue-close: 7
# Action can not skip PRs, set it to 100 years to cover it.
days-before-pr-stale: 36524
# Labels
stale-issue-label: 'stale'

View File

@@ -5,7 +5,6 @@ project_name: beszel
before:
hooks:
- go mod tidy
- go generate -run fetchsmartctl ./agent
builds:
- id: beszel
@@ -16,21 +15,10 @@ builds:
goos:
- linux
- darwin
- windows
- freebsd
goarch:
- amd64
- arm64
- arm
ignore:
- goos: windows
goarch: arm64
- goos: windows
goarch: arm
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: arm
- id: beszel-agent
binary: beszel-agent
@@ -97,9 +85,6 @@ archives:
{{ .Binary }}_
{{- .Os }}_
{{- .Arch }}
format_overrides:
- goos: windows
formats: [zip]
nfpms:
- id: beszel-agent

View File

@@ -7,7 +7,7 @@ SKIP_WEB ?= false
# Set executable extension based on target OS
EXE_EXT := $(if $(filter windows,$(OS)),.exe,)
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales fetch-smartctl-conditional
.PHONY: tidy build-agent build-hub build-hub-dev build clean lint dev-server dev-agent dev-hub dev generate-locales
.DEFAULT_GOAL := build
clean:
@@ -46,14 +46,8 @@ build-dotnet-conditional:
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
build-agent: tidy build-dotnet-conditional fetch-smartctl-conditional
build-agent: tidy build-dotnet-conditional
GOOS=$(OS) GOARCH=$(ARCH) go build -o ./build/beszel-agent_$(OS)_$(ARCH)$(EXE_EXT) -ldflags "-w -s" ./internal/cmd/agent
build-hub: tidy $(if $(filter false,$(SKIP_WEB)),build-web-ui)

View File

@@ -12,12 +12,10 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/host"
gossh "golang.org/x/crypto/ssh"
@@ -31,15 +29,12 @@ type Agent struct {
fsNames []string // List of filesystem device names being monitored
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
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
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
dockerManager *dockerManager // Manages Docker API requests
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic)
systemDetails system.Details // Host system details (static, once-per-connection)
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events
@@ -48,7 +43,6 @@ type Agent struct {
dataDir string // Directory for persisting data
keys []gossh.PublicKey // SSH public keys
smartManager *SmartManager // Manages SMART data
systemdManager *systemdManager // Manages systemd services
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -74,17 +68,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
agent.memCalc, _ = GetEnv("MEM_CALC")
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
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) {
@@ -100,21 +83,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version)
// initialize docker manager
agent.dockerManager = newDockerManager()
// initialize system info
agent.refreshSystemDetails()
// SMART_INTERVAL env var to update smart data at this interval
if smartIntervalEnv, exists := GetEnv("SMART_INTERVAL"); exists {
if duration, err := time.ParseDuration(smartIntervalEnv); err == nil && duration > 0 {
agent.systemDetails.SmartInterval = duration
slog.Info("SMART_INTERVAL", "duration", duration)
} else {
slog.Warn("Invalid SMART_INTERVAL", "err", err)
}
}
agent.initializeSystemInfo()
// initialize connection manager
agent.connectionManager = newConnectionManager(agent)
@@ -128,10 +98,8 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// initialize net io stats
agent.initializeNetIoStats()
agent.systemdManager, err = newSystemdManager()
if err != nil {
slog.Debug("Systemd", "err", err)
}
// initialize docker manager
agent.dockerManager = newDockerManager(agent)
agent.smartManager, err = NewSmartManager()
if err != nil {
@@ -146,7 +114,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
slog.Debug("Stats", "data", agent.gatherStats(0))
}
return agent, nil
@@ -161,11 +129,10 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
a.Lock()
defer a.Unlock()
cacheTimeMs := options.CacheTimeMs
data, isCached := a.cache.Get(cacheTimeMs)
if isCached {
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
@@ -176,12 +143,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
Stats: a.getSystemStats(cacheTimeMs),
Info: a.systemInfo,
}
// Include static system details only when requested
if options.IncludeDetails {
data.Details = &a.systemDetails
}
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
if a.dockerManager != nil {
@@ -193,20 +154,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
// skip updating systemd services if cache time is not the default 60sec interval
if a.systemdManager != nil && cacheTimeMs == 60_000 {
totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 {
numFailed := a.systemdManager.getFailedServiceCount()
data.Info.Services = []uint16{totalCount, numFailed}
}
if a.systemdManager.hasFreshStats {
data.SystemdServices = a.systemdManager.getServiceStats(nil, false)
}
}
data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
// Use custom name if available, otherwise use device name
@@ -215,11 +163,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
key = stats.Name
}
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)
@@ -244,9 +187,8 @@ func (a *Agent) getFingerprint() string {
// if no fingerprint is found, generate one
fingerprint, err := host.HostID()
// we ignore a commonly known "product_uuid" known not to be unique
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
if err != nil || fingerprint == "" {
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
}
// hash fingerprint

View File

@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
DiskTotal: 100000,
},
Info: system.Info{
AgentVersion: "0.12.0",
Hostname: "test-host",
},
Containers: []*container.Stats{
{
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
Mem: 16384,
},
Info: system.Info{
AgentVersion: "0.12.0",
Hostname: "test-host-2",
},
Containers: []*container.Stats{},
}
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
Mem: 32768,
},
Info: system.Info{
AgentVersion: "0.12.0",
Hostname: "updated-host",
},
Containers: []*container.Stats{},
}

View File

@@ -6,7 +6,6 @@ package battery
import (
"errors"
"log/slog"
"math"
"github.com/distatus/battery"
)
@@ -52,8 +51,6 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
totalCharge := float64(0)
errs, partialErrs := err.(battery.Errors)
batteryState = math.MaxUint8
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
@@ -66,12 +63,9 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
}
totalCapacity += bat.Full
totalCharge += bat.Current
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}
}
if totalCapacity == 0 || batteryState == math.MaxUint8 {
if totalCapacity == 0 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
@@ -80,5 +74,6 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
batteryState = uint8(batteries[0].State.Raw)
return batteryPercent, batteryState, nil
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
@@ -198,7 +200,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
if authRequest.NeedSysInfo {
response.Name, _ = GetEnv("SYSTEM_NAME")
response.Hostname = client.agent.systemDetails.Hostname
response.Hostname = client.agent.systemInfo.Hostname
serverAddr := client.agent.connectionManager.serverOptions.Addr
_, response.Port, _ = net.SplitHostPort(serverAddr)
}
@@ -256,16 +258,38 @@ func (client *WebSocketClient) sendMessage(data any) error {
return err
}
// sendResponse sends a response with optional request ID.
// For ID-based requests, we must populate legacy typed fields for backward
// compatibility with older hubs (<= 0.17) that don't read the generic Data field.
// sendResponse sends a response with optional request ID for the new protocol
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
if requestID != nil {
response := newAgentResponse(data, requestID)
// New format with ID - use typed fields
response := common.AgentResponse{
Id: requestID,
}
// Set the appropriate typed field based on data type
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
// case []byte:
// response.RawBytes = v
// case string:
// response.RawBytes = []byte(v)
default:
// For any other type, convert to error
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return client.sendMessage(response)
} else {
// Legacy format - send data directly
return client.sendMessage(data)
}
// Legacy format - send data directly
return client.sendMessage(data)
}
// getUserAgent returns one of two User-Agent strings based on current time.

View File

@@ -2,14 +2,19 @@ package agent
import (
"math"
"path/filepath"
"runtime"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/process"
)
var lastCpuTimes = make(map[uint16]cpu.TimesStat)
var lastPerCoreCpuTimes = make(map[uint16][]cpu.TimesStat)
var lastProcessCpuTimes = make(map[uint16]map[int32]float64)
var lastProcessCpuSampleTime = make(map[uint16]time.Time)
// init initializes the CPU monitoring by storing the initial CPU times
// for the default 60-second cache interval.
@@ -20,6 +25,16 @@ func init() {
if perCoreTimes, err := cpu.Times(true); err == nil {
lastPerCoreCpuTimes[60000] = perCoreTimes
}
if processes, err := process.Processes(); err == nil {
snapshot := make(map[int32]float64, len(processes))
for _, proc := range processes {
if times, err := proc.Times(); err == nil {
snapshot[proc.Pid] = times.Total()
}
}
lastProcessCpuTimes[60000] = snapshot
lastProcessCpuSampleTime[60000] = time.Now()
}
}
// CpuMetrics contains detailed CPU usage breakdown
@@ -105,6 +120,110 @@ func getPerCoreCpuUsage(cacheTimeMs uint16) (system.Uint8Slice, error) {
return usage, nil
}
// getTopCpuProcess returns the process with the highest CPU usage since the last run
// for the given cache interval. It returns nil if insufficient data is available.
func getTopCpuProcess(cacheTimeMs uint16) (*system.TopCpuProcess, error) {
processes, err := process.Processes()
if err != nil {
return nil, err
}
now := time.Now()
lastTimes, ok := lastProcessCpuTimes[cacheTimeMs]
if !ok {
if fallback := lastProcessCpuTimes[60000]; fallback != nil {
copied := make(map[int32]float64, len(fallback))
for pid, total := range fallback {
copied[pid] = total
}
lastTimes = copied
lastProcessCpuTimes[cacheTimeMs] = copied
} else {
lastTimes = make(map[int32]float64)
lastProcessCpuTimes[cacheTimeMs] = lastTimes
}
}
lastSample := lastProcessCpuSampleTime[cacheTimeMs]
if lastSample.IsZero() {
if fallback := lastProcessCpuSampleTime[60000]; !fallback.IsZero() {
lastSample = fallback
lastProcessCpuSampleTime[cacheTimeMs] = fallback
}
}
elapsed := now.Sub(lastSample).Seconds()
if lastSample.IsZero() || elapsed <= 0 {
snapshot := make(map[int32]float64, len(processes))
for _, proc := range processes {
if times, err := proc.Times(); err == nil {
snapshot[proc.Pid] = times.Total()
}
}
lastProcessCpuTimes[cacheTimeMs] = snapshot
lastProcessCpuSampleTime[cacheTimeMs] = now
return nil, nil
}
cpuCount := float64(runtime.NumCPU())
if cpuCount <= 0 {
cpuCount = 1
}
snapshot := make(map[int32]float64, len(processes))
var topName string
var topPercent float64
for _, proc := range processes {
times, err := proc.Times()
if err != nil {
continue
}
total := times.Total()
pid := proc.Pid
snapshot[pid] = total
lastTotal, ok := lastTimes[pid]
if !ok || total <= lastTotal {
continue
}
percent := clampPercent((total - lastTotal) / (elapsed * cpuCount) * 100)
if percent <= 0 {
continue
}
name, err := proc.Name()
if err != nil || name == "" {
if exe, exeErr := proc.Exe(); exeErr == nil && exe != "" {
name = filepath.Base(exe)
}
}
if name == "" {
continue
}
if percent > topPercent {
topPercent = percent
topName = name
}
}
lastProcessCpuTimes[cacheTimeMs] = snapshot
lastProcessCpuSampleTime[cacheTimeMs] = now
if topName == "" {
return nil, nil
}
return &system.TopCpuProcess{
Name: topName,
Percent: topPercent,
}, nil
}
// calculateBusy calculates the CPU busy percentage between two time points.
// It computes the ratio of busy time to total time elapsed between t1 and t2,
// returning a percentage clamped between 0 and 100.

View File

@@ -95,9 +95,6 @@ func (a *Agent) initializeDiskInfo() {
}
}
// Get the appropriate root mount point for this system
rootMountPoint := a.getRootMountPoint()
// Use FILESYSTEM env var to find root filesystem
if filesystem != "" {
for _, p := range partitions {
@@ -141,7 +138,7 @@ func (a *Agent) initializeDiskInfo() {
for _, p := range partitions {
// fmt.Println(p.Device, p.Mountpoint)
// Binary root fallback or docker root fallback
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
if match {
addFsStat(fs, p.Mountpoint, true)
@@ -177,8 +174,8 @@ func (a *Agent) initializeDiskInfo() {
// If no root filesystem set, use fallback
if !hasRoot {
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
}
a.initializeDiskIoStats(diskIoCounters)
@@ -225,19 +222,8 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
// Updates disk usage statistics for all monitored filesystems
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
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 {
stats.DiskTotal = bytesToGigabytes(d.Total)
stats.DiskUsed = bytesToGigabytes(d.Used)
@@ -255,11 +241,6 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
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
@@ -331,32 +312,3 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
}
}
}
// getRootMountPoint returns the appropriate root mount point for the system
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string {
// 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent)
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
strings.Contains(content, "coreos") ||
strings.Contains(content, "flatcar") ||
strings.Contains(content, "rhel-atomic") ||
strings.Contains(content, "centos-atomic") {
// Verify that /sysroot exists before returning it
if _, err := os.Stat("/sysroot"); err == nil {
return "/sysroot"
}
}
}
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
if _, err := os.Stat("/run/ostree"); err == nil {
// Verify that /sysroot exists before returning it
if _, err := os.Stat("/sysroot"); err == nil {
return "/sysroot"
}
}
return "/"
}

View File

@@ -7,7 +7,6 @@ import (
"os"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
@@ -234,86 +233,3 @@ func TestExtraFsKeyGeneration(t *testing.T) {
})
}
}
func TestDiskUsageCaching(t *testing.T) {
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 0, // caching disabled
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// Both should be updated (non-zero values from disk.Usage)
// Root stats should be populated in systemStats
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
"lastDiskUsageUpdate should be set when caching is disabled")
})
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
},
diskUsageCacheDuration: 1 * time.Hour,
lastDiskUsageUpdate: time.Now(), // cache is fresh
}
// Store original extra fs values
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
var stats system.Stats
agent.updateDiskUsage(&stats)
// Root should be updated (systemStats populated from disk.Usage call)
// We can't easily check if disk.Usage was called, but we verify the flow works
// Extra filesystem should retain cached values (not reset)
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
"extra filesystem DiskTotal should be unchanged when cached")
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
"extra filesystem DiskUsed should be unchanged when cached")
})
t.Run("first call always updates all filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 1 * time.Hour,
// lastDiskUsageUpdate is zero (first call)
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// After first call, lastDiskUsageUpdate should be set
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
"lastDiskUsageUpdate should be set after first call")
})
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
agent := &Agent{
fsStats: map[string]*system.FsStats{
"sda": {Root: true, Mountpoint: "/"},
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
},
diskUsageCacheDuration: 1 * time.Millisecond,
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
}
var stats system.Stats
agent.updateDiskUsage(&stats)
// lastDiskUsageUpdate should be refreshed since cache expired
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
"lastDiskUsageUpdate should be refreshed when cache expires")
})
}

View File

@@ -14,7 +14,6 @@ import (
"net/url"
"os"
"path"
"regexp"
"strings"
"sync"
"time"
@@ -25,10 +24,6 @@ import (
"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 (
// Docker API timeout in milliseconds
dockerTimeoutMs = 2100
@@ -60,7 +55,6 @@ type dockerManager struct {
decoder *json.Decoder // Reusable JSON decoder that reads from buf
apiStats *container.ApiStats // Reusable API stats object
excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -479,7 +473,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
}
// Creates a new http client for Docker or Podman API
func newDockerManager() *dockerManager {
func newDockerManager(a *Agent) *dockerManager {
dockerHost, exists := GetEnv("DOCKER_HOST")
if exists {
// return nil if set to empty string
@@ -565,7 +559,7 @@ func newDockerManager() *dockerManager {
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
manager.usingPodman = true
a.systemInfo.Podman = true
manager.goodDockerVersion = true
return manager
}
@@ -698,17 +692,13 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
return "", err
}
// Strip ANSI escape sequences from logs for clean display in web UI
logs := builder.String()
if strings.Contains(logs, "\x1b") {
logs = ansiEscapePattern.ReplaceAllString(logs, "")
}
return logs, nil
return builder.String(), nil
}
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
const headerSize = 8
var header [headerSize]byte
buf := make([]byte, 0, dockerLogsTail*200)
totalBytesRead := 0
for {
@@ -732,37 +722,36 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
// 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))
_, _ = io.Copy(io.Discard, io.LimitReader(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 {
buf = allocateBuffer(buf, int(frameLen))
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
if len(buf) > 0 {
builder.Write(buf[:min(int(frameLen), len(buf))])
}
return nil
}
return err
}
totalBytesRead += int(n)
builder.Write(buf[:frameLen])
totalBytesRead += int(frameLen)
}
}
// GetHostInfo fetches the system info from Docker
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
resp, err := dm.client.Get("http://localhost/info")
if err != nil {
return info, err
func allocateBuffer(current []byte, needed int) []byte {
if cap(current) >= needed {
return current[:needed]
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return info, err
}
return info, nil
return make([]byte, needed)
}
func (dm *dockerManager) IsPodman() bool {
return dm.usingPodman
func min(a, b int) int {
if a < b {
return a
}
return b
}

View File

@@ -802,24 +802,6 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
}
}
func TestGetHostInfo(t *testing.T) {
data, err := os.ReadFile("test-data/system_info.json")
require.NoError(t, err)
var info container.HostInfo
err = json.Unmarshal(data, &info)
require.NoError(t, err)
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
// assert.Equal(t, "24.04", info.OSVersion)
// assert.Equal(t, "linux", info.OSType)
// assert.Equal(t, "x86_64", info.Architecture)
assert.EqualValues(t, 4, info.NCPU)
assert.EqualValues(t, 2095882240, info.MemTotal)
// assert.Equal(t, "27.0.1", info.ServerVersion)
}
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
// Test that different cache times have separate DeltaTracker instances
dm := &dockerManager{
@@ -1071,6 +1053,53 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
})
}
func TestAllocateBuffer(t *testing.T) {
tests := []struct {
name string
currentCap int
needed int
expectedCap int
shouldRealloc bool
}{
{
name: "buffer has enough capacity",
currentCap: 1024,
needed: 512,
expectedCap: 1024,
shouldRealloc: false,
},
{
name: "buffer needs reallocation",
currentCap: 512,
needed: 1024,
expectedCap: 1024,
shouldRealloc: true,
},
{
name: "buffer needs exact size",
currentCap: 1024,
needed: 1024,
expectedCap: 1024,
shouldRealloc: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
current := make([]byte, 0, tt.currentCap)
result := allocateBuffer(current, tt.needed)
assert.Equal(t, tt.needed, len(result))
assert.GreaterOrEqual(t, cap(result), tt.expectedCap)
if tt.shouldRealloc {
// If reallocation was needed, capacity should be at least the needed size
assert.GreaterOrEqual(t, cap(result), tt.needed)
}
})
}
}
func TestShouldExcludeContainer(t *testing.T) {
tests := []struct {
name string
@@ -1174,59 +1203,3 @@ func TestShouldExcludeContainer(t *testing.T) {
})
}
}
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)
})
}
}

View File

@@ -15,7 +15,7 @@ import (
"github.com/henrygd/beszel/internal/entities/system"
"log/slog"
"golang.org/x/exp/slog"
)
const (
@@ -44,7 +44,6 @@ type GPUManager struct {
rocmSmi bool
tegrastats bool
intelGpuStats bool
nvml bool
GpuDataMap map[string]*system.GPUData
// lastAvgData stores the last calculated averages for each GPU
// Used when a collection happens before new data arrives (Count == 0)
@@ -298,13 +297,8 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
currentCount := uint32(gpu.Count)
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
// If no new data arrived
// If no new data arrived, use last known average
if deltaCount == 0 {
// If GPU appears suspended (instantaneous values are 0), return zero values
// Otherwise return last known average for temporary collection gaps
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
return system.GPUData{Name: gpu.Name}
}
return gm.lastAvgData[id] // zero value if not found
}
@@ -402,7 +396,7 @@ func (gm *GPUManager) detectGPUs() error {
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
gm.intelGpuStats = true
}
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
return nil
}
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
@@ -473,20 +467,7 @@ func NewGPUManager() (*GPUManager, error) {
gm.GpuDataMap = make(map[string]*system.GPUData)
if gm.nvidiaSmi {
if nvml, _ := GetEnv("NVML"); nvml == "true" {
gm.nvml = true
gm.nvidiaSmi = false
collector := &nvmlCollector{gm: &gm}
if err := collector.init(); err == nil {
go collector.start()
} else {
slog.Warn("Failed to initialize NVML, falling back to nvidia-smi", "err", err)
gm.nvidiaSmi = true
gm.startCollector(nvidiaSmiCmd)
}
} else {
gm.startCollector(nvidiaSmiCmd)
}
gm.startCollector(nvidiaSmiCmd)
}
if gm.rocmSmi {
gm.startCollector(rocmSmiCmd)

View File

@@ -134,9 +134,7 @@ func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineN
powerIndex = -1 // Initialize to -1, will be set to actual index if found
// Collect engine names from header1
for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '/'
})
key := strings.TrimRightFunc(col, func(r rune) bool { return r >= '0' && r <= '9' })
var friendly string
switch key {
case "RCS":

View File

@@ -1,222 +0,0 @@
package agent
import (
"fmt"
"log/slog"
"strings"
"time"
"unsafe"
"github.com/ebitengine/purego"
"github.com/henrygd/beszel/internal/entities/system"
)
// NVML constants and types
const (
nvmlSuccess int = 0
)
type nvmlDevice uintptr
type nvmlReturn int
type nvmlMemoryV1 struct {
Total uint64
Free uint64
Used uint64
}
type nvmlMemoryV2 struct {
Version uint32
Total uint64
Reserved uint64
Free uint64
Used uint64
}
type nvmlUtilization struct {
Gpu uint32
Memory uint32
}
type nvmlPciInfo struct {
BusId [16]byte
Domain uint32
Bus uint32
Device uint32
PciDeviceId uint32
PciSubSystemId uint32
}
// NVML function signatures
var (
nvmlInit func() nvmlReturn
nvmlShutdown func() nvmlReturn
nvmlDeviceGetCount func(count *uint32) nvmlReturn
nvmlDeviceGetHandleByIndex func(index uint32, device *nvmlDevice) nvmlReturn
nvmlDeviceGetName func(device nvmlDevice, name *byte, length uint32) nvmlReturn
nvmlDeviceGetMemoryInfo func(device nvmlDevice, memory uintptr) nvmlReturn
nvmlDeviceGetUtilizationRates func(device nvmlDevice, utilization *nvmlUtilization) nvmlReturn
nvmlDeviceGetTemperature func(device nvmlDevice, sensorType int, temp *uint32) nvmlReturn
nvmlDeviceGetPowerUsage func(device nvmlDevice, power *uint32) nvmlReturn
nvmlDeviceGetPciInfo func(device nvmlDevice, pci *nvmlPciInfo) nvmlReturn
nvmlErrorString func(result nvmlReturn) string
)
type nvmlCollector struct {
gm *GPUManager
lib uintptr
devices []nvmlDevice
bdfs []string
isV2 bool
}
func (c *nvmlCollector) init() error {
slog.Debug("NVML: Initializing")
libPath := getNVMLPath()
lib, err := openLibrary(libPath)
if err != nil {
return fmt.Errorf("failed to load %s: %w", libPath, err)
}
c.lib = lib
purego.RegisterLibFunc(&nvmlInit, lib, "nvmlInit")
purego.RegisterLibFunc(&nvmlShutdown, lib, "nvmlShutdown")
purego.RegisterLibFunc(&nvmlDeviceGetCount, lib, "nvmlDeviceGetCount")
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
// Try to get v2 memory info, fallback to v1 if not available
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
c.isV2 = true
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
} else {
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo")
}
purego.RegisterLibFunc(&nvmlDeviceGetUtilizationRates, lib, "nvmlDeviceGetUtilizationRates")
purego.RegisterLibFunc(&nvmlDeviceGetTemperature, lib, "nvmlDeviceGetTemperature")
purego.RegisterLibFunc(&nvmlDeviceGetPowerUsage, lib, "nvmlDeviceGetPowerUsage")
purego.RegisterLibFunc(&nvmlDeviceGetPciInfo, lib, "nvmlDeviceGetPciInfo")
purego.RegisterLibFunc(&nvmlErrorString, lib, "nvmlErrorString")
if ret := nvmlInit(); ret != nvmlReturn(nvmlSuccess) {
return fmt.Errorf("nvmlInit failed: %v", ret)
}
var count uint32
if ret := nvmlDeviceGetCount(&count); ret != nvmlReturn(nvmlSuccess) {
return fmt.Errorf("nvmlDeviceGetCount failed: %v", ret)
}
for i := uint32(0); i < count; i++ {
var device nvmlDevice
if ret := nvmlDeviceGetHandleByIndex(i, &device); ret == nvmlReturn(nvmlSuccess) {
c.devices = append(c.devices, device)
// Get BDF for power state check
var pci nvmlPciInfo
if ret := nvmlDeviceGetPciInfo(device, &pci); ret == nvmlReturn(nvmlSuccess) {
busID := string(pci.BusId[:])
if idx := strings.Index(busID, "\x00"); idx != -1 {
busID = busID[:idx]
}
c.bdfs = append(c.bdfs, strings.ToLower(busID))
} else {
c.bdfs = append(c.bdfs, "")
}
}
}
return nil
}
func (c *nvmlCollector) start() {
defer nvmlShutdown()
ticker := time.Tick(3 * time.Second)
for range ticker {
c.collect()
}
}
func (c *nvmlCollector) collect() {
c.gm.Lock()
defer c.gm.Unlock()
for i, device := range c.devices {
id := fmt.Sprintf("%d", i)
bdf := c.bdfs[i]
// Update GPUDataMap
if _, ok := c.gm.GpuDataMap[id]; !ok {
var nameBuf [64]byte
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
continue
}
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
name = strings.TrimPrefix(name, "NVIDIA ")
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
}
gpu := c.gm.GpuDataMap[id]
if bdf != "" && !c.isGPUActive(bdf) {
slog.Debug("NVML: GPU is suspended, skipping", "bdf", bdf)
gpu.Temperature = 0
gpu.MemoryUsed = 0
continue
}
// Utilization
var utilization nvmlUtilization
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
gpu.Temperature = 0
gpu.MemoryUsed = 0
continue
}
slog.Debug("NVML: Collecting data for GPU", "bdf", bdf)
// Temperature
var temp uint32
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
// Memory: only poll if GPU is active to avoid leaving D3cold state (#1522)
if utilization.Gpu > 0 {
var usedMem, totalMem uint64
if c.isV2 {
var memory nvmlMemoryV2
memory.Version = 0x02000028 // (2 << 24) | 40 bytes
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo_v2 failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
} else {
var memory nvmlMemoryV1
if ret := nvmlDeviceGetMemoryInfo(device, uintptr(unsafe.Pointer(&memory))); ret != nvmlReturn(nvmlSuccess) {
slog.Debug("NVML: MemoryInfo failed", "bdf", bdf, "ret", ret)
} else {
usedMem = memory.Used
totalMem = memory.Total
}
}
if totalMem > 0 {
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
}
} else {
slog.Debug("NVML: Skipping memory info (utilization=0)", "bdf", bdf)
}
// Power
var power uint32
nvmlDeviceGetPowerUsage(device, &power)
gpu.Temperature = float64(temp)
gpu.Usage += float64(utilization.Gpu)
gpu.Power += float64(power) / 1000.0
gpu.Count++
slog.Debug("NVML: Collected data", "gpu", gpu)
}
}

View File

@@ -1,57 +0,0 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"strings"
"github.com/ebitengine/purego"
"log/slog"
)
func openLibrary(name string) (uintptr, error) {
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
}
func getNVMLPath() string {
return "libnvidia-ml.so.1"
}
func hasSymbol(lib uintptr, symbol string) bool {
_, err := purego.Dlsym(lib, symbol)
return err == nil
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
// runtime_status
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
status, err := os.ReadFile(statusPath)
if err != nil {
slog.Debug("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
return true // Assume active if we can't read status
}
statusStr := strings.TrimSpace(string(status))
if statusStr != "active" && statusStr != "resuming" {
slog.Debug("NVML: GPU not active", "bdf", bdf, "status", statusStr)
return false
}
// power_state (D0 check)
// Find any drm card device power_state
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
matches, _ := filepath.Glob(pstatePathPattern)
if len(matches) > 0 {
pstate, err := os.ReadFile(matches[0])
if err == nil {
pstateStr := strings.TrimSpace(string(pstate))
if pstateStr != "D0" {
slog.Debug("NVML: GPU not in D0 state", "bdf", bdf, "pstate", pstateStr)
return false
}
}
}
return true
}

View File

@@ -1,21 +0,0 @@
//go:build !linux && !windows
package agent
import "fmt"
func openLibrary(name string) (uintptr, error) {
return 0, fmt.Errorf("nvml not supported on this platform")
}
func getNVMLPath() string {
return ""
}
func hasSymbol(lib uintptr, symbol string) bool {
return false
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
return true
}

View File

@@ -1,25 +0,0 @@
//go:build windows
package agent
import (
"golang.org/x/sys/windows"
)
func openLibrary(name string) (uintptr, error) {
handle, err := windows.LoadLibrary(name)
return uintptr(handle), err
}
func getNVMLPath() string {
return "nvml.dll"
}
func hasSymbol(lib uintptr, symbol string) bool {
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
return err == nil
}
func (c *nvmlCollector) isGPUActive(bdf string) bool {
return true
}

View File

@@ -825,7 +825,7 @@ func TestInitializeSnapshots(t *testing.T) {
}
func TestCalculateGPUAverage(t *testing.T) {
t.Run("returns cached average when deltaCount is zero", func(t *testing.T) {
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
@@ -838,10 +838,9 @@ func TestCalculateGPUAverage(t *testing.T) {
}
gpu := &system.GPUData{
Count: 10.0, // Same as snapshot, so delta = 0
Usage: 100.0,
Power: 200.0,
Temperature: 50.0, // Non-zero to avoid "suspended" check
Count: 10.0, // Same as snapshot, so delta = 0
Usage: 100.0,
Power: 200.0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
@@ -850,31 +849,6 @@ func TestCalculateGPUAverage(t *testing.T) {
assert.Equal(t, 100.0, result.Power, "Should return cached average")
})
t.Run("returns zero value when GPU is suspended", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
5000: {
"0": {count: 10, usage: 100, power: 200},
},
},
lastAvgData: map[string]system.GPUData{
"0": {Usage: 50.0, Power: 100.0},
},
}
gpu := &system.GPUData{
Name: "Test GPU",
Count: 10.0,
Temperature: 0,
MemoryUsed: 0,
}
result := gm.calculateGPUAverage("0", gpu, 5000)
assert.Equal(t, 0.0, result.Usage, "Should return zero usage")
assert.Equal(t, 0.0, result.Power, "Should return zero power")
})
t.Run("calculates average for standard GPU", func(t *testing.T) {
gm := &GPUManager{
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
@@ -1465,15 +1439,6 @@ func TestParseIntelHeaders(t *testing.T) {
wantPowerIndex: 4, // "gpu" is at index 4
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",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",

View File

@@ -9,7 +9,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"log/slog"
"golang.org/x/exp/slog"
)
// HandlerContext provides context for request handlers
@@ -50,7 +50,6 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerLogs, &GetContainerLogsHandler{})
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
return registry
}
@@ -94,7 +93,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
var options common.DataRequestOptions
_ = cbor.Unmarshal(hctx.Request.Data, &options)
sysStats := hctx.Agent.gatherStats(options)
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
return hctx.SendResponse(sysStats, hctx.RequestID)
}
@@ -175,31 +174,3 @@ func (h *GetSmartDataHandler) Handle(hctx *HandlerContext) error {
data := hctx.Agent.smartManager.GetCurrentData()
return hctx.SendResponse(data, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdInfoHandler handles detailed systemd service info requests
type GetSystemdInfoHandler struct{}
func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdInfoRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
details, err := hctx.Agent.systemdManager.getServiceDetails(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(details, hctx.RequestID)
}

View File

@@ -1,31 +0,0 @@
package agent
import (
"github.com/fxamacker/cbor/v2"
"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/systemd"
)
// newAgentResponse creates an AgentResponse using legacy typed fields.
// This maintains backward compatibility with <= 0.17 hubs that expect specific fields.
func newAgentResponse(data any, requestID *uint32) common.AgentResponse {
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case *common.FingerprintResponse:
response.Fingerprint = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
case systemd.ServiceDetails:
response.ServiceInfo = v
default:
// For unknown types, use the generic Data field
response.Data, _ = cbor.Marshal(data)
}
return response
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver"
@@ -163,9 +164,18 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
}
// responder that writes AgentResponse to stdout
// Uses legacy typed fields for backward compatibility with <= 0.17
sshResponder := func(data any, requestID *uint32) error {
response := newAgentResponse(data, requestID)
response := common.AgentResponse{Id: requestID}
switch v := data.(type) {
case *system.CombinedData:
response.SystemData = v
case string:
response.String = &v
case map[string]smart.SmartData:
response.SmartData = v
default:
response.Error = fmt.Sprintf("unsupported response type: %T", data)
}
return cbor.NewEncoder(w).Encode(response)
}
@@ -189,7 +199,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
stats := a.gatherStats(60_000)
return a.writeToSession(w, stats, hubVersion)
}

View File

@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
err = json.Unmarshal([]byte(encodedData), &decodedJson)
assert.Error(t, err, "Should not be valid JSON data")
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
} else {
// Should be JSON - try to decode as JSON
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
assert.Error(t, err, "Should not be valid CBOR data")
// Verify the decoded JSON data matches our test data
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
// Verify it looks like JSON (starts with '{' and contains readable field names)
@@ -550,12 +550,13 @@ func createTestCombinedData() *system.CombinedData {
DiskUsed: 549755813888, // 512GB
DiskPct: 50.0,
},
Details: &system.Details{
Hostname: "test-host",
},
Info: system.Info{
Hostname: "test-host",
Cores: 8,
CpuModel: "Test CPU Model",
Uptime: 3600,
AgentVersion: "0.12.0",
Os: system.Linux,
},
Containers: []*container.Stats{
{

View File

@@ -1,6 +1,3 @@
//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
import (
@@ -8,10 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
@@ -19,18 +13,16 @@ import (
"github.com/henrygd/beszel/internal/entities/smart"
"log/slog"
"golang.org/x/exp/slog"
)
// SmartManager manages data collection for SMART devices
type SmartManager struct {
sync.Mutex
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
binPath string
excludedDevices map[string]struct{}
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
}
type scanOutput struct {
@@ -168,7 +160,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, sm.binPath, "--scan", "-j")
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
output, err := cmd.Output()
var (
@@ -187,7 +179,6 @@ func (sm *SmartManager) ScanDevices(force bool) error {
}
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
finalDevices = sm.filterExcludedDevices(finalDevices)
sm.updateSmartDevices(finalDevices)
if len(finalDevices) == 0 {
@@ -235,47 +226,6 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
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.
@@ -422,21 +372,17 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
// for initial data collection when no cached data exists
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
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
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Try with -n standby first if we have existing data
args := sm.smartctlArgs(deviceInfo, true)
cmd := exec.CommandContext(ctx, sm.binPath, args...)
cmd := exec.CommandContext(ctx, "smartctl", args...)
output, err := cmd.CombinedOutput()
// Check if device is in standby (exit status 2)
@@ -446,49 +392,21 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
return nil
}
// No cached data, need to collect initial data by bypassing standby
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel2()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
cmd = exec.CommandContext(ctx2, "smartctl", args...)
output, err = cmd.CombinedOutput()
}
hasValidData := sm.parseSmartOutput(deviceInfo, output)
// If NVMe controller path failed, try namespace path as fallback.
// NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504
if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) {
controllerPath := deviceInfo.Name
namespacePath := controllerPath + "n1"
if !sm.isExcludedDevice(namespacePath) {
deviceInfo.Name = namespacePath
ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel3()
args = sm.smartctlArgs(deviceInfo, false)
cmd = exec.CommandContext(ctx3, sm.binPath, args...)
output, err = cmd.CombinedOutput()
hasValidData = sm.parseSmartOutput(deviceInfo, output)
// Auto-exclude the controller path so future scans don't re-add it
if hasValidData {
sm.Lock()
if sm.excludedDevices == nil {
sm.excludedDevices = make(map[string]struct{})
}
sm.excludedDevices[controllerPath] = struct{}{}
sm.Unlock()
slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
}
}
}
if !hasValidData {
if err != nil {
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
return err
}
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
return errNoValidSmartData
}
@@ -957,54 +875,13 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
}
// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() (string, error) {
isWindows := runtime.GOOS == "windows"
// Load embedded smartctl.exe for Windows amd64 builds.
if isWindows && runtime.GOARCH == "amd64" {
if path, err := ensureEmbeddedSmartctl(); err == nil {
return path, nil
}
func (sm *SmartManager) detectSmartctl() error {
if _, err := exec.LookPath("smartctl"); err == nil {
slog.Debug("smartctl found")
return nil
}
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")
}
// isNvmeControllerPath checks if the path matches an NVMe controller pattern
// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1)
func isNvmeControllerPath(path string) bool {
base := filepath.Base(path)
if !strings.HasPrefix(base, "nvme") {
return false
}
suffix := strings.TrimPrefix(base, "nvme")
if suffix == "" {
return false
}
// Controller paths are just "nvme" + digits (e.g., nvme0, nvme1)
// Namespace paths have "n" after the controller number (e.g., nvme0n1)
for _, c := range suffix {
if c < '0' || c > '9' {
return false
}
}
return true
slog.Debug("smartctl not found")
return errors.New("smartctl not found")
}
// NewSmartManager creates and initializes a new SmartManager
@@ -1012,13 +889,9 @@ func NewSmartManager() (*SmartManager, error) {
sm := &SmartManager{
SmartDataMap: make(map[string]*smart.SmartData),
}
sm.refreshExcludedDevices()
path, err := sm.detectSmartctl()
if err != nil {
slog.Debug(err.Error())
if err := sm.detectSmartctl(); err != nil {
return nil, err
}
slog.Debug("smartctl", "path", path)
sm.binPath = path
return sm, nil
}

View File

@@ -1,9 +0,0 @@
//go:build !windows
package agent
import "errors"
func ensureEmbeddedSmartctl() (string, error) {
return "", errors.ErrUnsupported
}

View File

@@ -588,228 +588,3 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
})
}
}
func TestRefreshExcludedDevices(t *testing.T) {
tests := []struct {
name string
envValue string
expectedDevs map[string]struct{}
}{
{
name: "empty env",
envValue: "",
expectedDevs: map[string]struct{}{},
},
{
name: "single device",
envValue: "/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
},
},
{
name: "multiple devices",
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "devices with whitespace",
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
"/dev/nvme0": {},
},
},
{
name: "duplicate devices",
envValue: "/dev/sda,/dev/sdb,/dev/sda",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
{
name: "empty entries and whitespace",
envValue: "/dev/sda,, /dev/sdb , , ",
expectedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
t.Setenv("EXCLUDE_SMART", tt.envValue)
} else {
// Ensure env var is not set for empty test
os.Unsetenv("EXCLUDE_SMART")
}
sm := &SmartManager{}
sm.refreshExcludedDevices()
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
})
}
}
func TestIsExcludedDevice(t *testing.T) {
sm := &SmartManager{
excludedDevices: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
}
tests := []struct {
name string
deviceName string
expectedBool bool
}{
{"excluded device sda", "/dev/sda", true},
{"excluded device nvme0", "/dev/nvme0", true},
{"non-excluded device sdb", "/dev/sdb", false},
{"non-excluded device nvme1", "/dev/nvme1", false},
{"empty device name", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sm.isExcludedDevice(tt.deviceName)
assert.Equal(t, tt.expectedBool, result)
})
}
}
func TestFilterExcludedDevices(t *testing.T) {
tests := []struct {
name string
excludedDevs map[string]struct{}
inputDevices []*DeviceInfo
expectedDevs []*DeviceInfo
expectedLength int
}{
{
name: "no exclusions",
excludedDevs: map[string]struct{}{},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
},
expectedLength: 3,
},
{
name: "some devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/nvme0": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
{Name: "/dev/nvme0"},
{Name: "/dev/nvme1"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
{Name: "/dev/nvme1"},
},
expectedLength: 2,
},
{
name: "all devices excluded",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
"/dev/sdb": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "nil devices",
excludedDevs: map[string]struct{}{},
inputDevices: nil,
expectedDevs: []*DeviceInfo{},
expectedLength: 0,
},
{
name: "filter nil and empty name devices",
excludedDevs: map[string]struct{}{
"/dev/sda": {},
},
inputDevices: []*DeviceInfo{
{Name: "/dev/sda"},
nil,
{Name: ""},
{Name: "/dev/sdb"},
},
expectedDevs: []*DeviceInfo{
{Name: "/dev/sdb"},
},
expectedLength: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sm := &SmartManager{
excludedDevices: tt.excludedDevs,
}
result := sm.filterExcludedDevices(tt.inputDevices)
assert.Len(t, result, tt.expectedLength)
assert.Equal(t, tt.expectedDevs, result)
})
}
}
func TestIsNvmeControllerPath(t *testing.T) {
tests := []struct {
path string
expected bool
}{
// Controller paths (should return true)
{"/dev/nvme0", true},
{"/dev/nvme1", true},
{"/dev/nvme10", true},
{"nvme0", true},
// Namespace paths (should return false)
{"/dev/nvme0n1", false},
{"/dev/nvme1n1", false},
{"/dev/nvme0n1p1", false},
{"nvme0n1", false},
// Non-NVMe paths (should return false)
{"/dev/sda", false},
{"/dev/sda1", false},
{"/dev/hda", false},
{"", false},
{"/dev/nvme", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := isNvmeControllerPath(tt.path)
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
})
}
}

View File

@@ -1,40 +0,0 @@
//go:build windows
package agent
import (
_ "embed"
"fmt"
"os"
"path/filepath"
"sync"
)
//go:embed smartmontools/smartctl.exe
var embeddedSmartctl []byte
var (
smartctlOnce sync.Once
smartctlPath string
smartctlErr error
)
func ensureEmbeddedSmartctl() (string, error) {
smartctlOnce.Do(func() {
destDir := filepath.Join(os.TempDir(), "beszel", "smartmontools")
if err := os.MkdirAll(destDir, 0o755); err != nil {
smartctlErr = fmt.Errorf("failed to create smartctl directory: %w", err)
return
}
destPath := filepath.Join(destDir, "smartctl.exe")
if err := os.WriteFile(destPath, embeddedSmartctl, 0o755); err != nil {
smartctlErr = fmt.Errorf("failed to write embedded smartctl: %w", err)
return
}
smartctlPath = destPath
})
return smartctlPath, smartctlErr
}

View File

@@ -2,18 +2,15 @@ package agent
import (
"bufio"
"errors"
"fmt"
"log/slog"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/cpu"
@@ -30,79 +27,41 @@ type prevDisk struct {
}
// Sets initial / non-changing values about the host system
func (a *Agent) refreshSystemDetails() {
func (a *Agent) initializeSystemInfo() {
a.systemInfo.AgentVersion = beszel.Version
// get host info from Docker if available
var hostInfo container.HostInfo
if a.dockerManager != nil {
a.systemDetails.Podman = a.dockerManager.IsPodman()
hostInfo, _ = a.dockerManager.GetHostInfo()
}
a.systemDetails.Hostname, _ = os.Hostname()
if arch, err := host.KernelArch(); err == nil {
a.systemDetails.Arch = arch
} else {
a.systemDetails.Arch = runtime.GOARCH
}
a.systemInfo.Hostname, _ = os.Hostname()
platform, _, version, _ := host.PlatformInformation()
if platform == "darwin" {
a.systemDetails.Os = system.Darwin
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
a.systemInfo.KernelVersion = version
a.systemInfo.Os = system.Darwin
} else if strings.Contains(platform, "indows") {
a.systemDetails.Os = system.Windows
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
a.systemDetails.Kernel = version
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
a.systemInfo.Os = system.Windows
} else if platform == "freebsd" {
a.systemDetails.Os = system.Freebsd
a.systemDetails.Kernel, _ = host.KernelVersion()
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = "FreeBSD"
}
a.systemInfo.Os = system.Freebsd
a.systemInfo.KernelVersion = version
} else {
a.systemDetails.Os = system.Linux
a.systemDetails.OsName = hostInfo.OperatingSystem
if a.systemDetails.OsName == "" {
if prettyName, err := getOsPrettyName(); err == nil {
a.systemDetails.OsName = prettyName
} else {
a.systemDetails.OsName = platform
}
}
a.systemDetails.Kernel = hostInfo.KernelVersion
if a.systemDetails.Kernel == "" {
a.systemDetails.Kernel, _ = host.KernelVersion()
}
a.systemInfo.Os = system.Linux
}
if a.systemInfo.KernelVersion == "" {
a.systemInfo.KernelVersion, _ = host.KernelVersion()
}
// cpu model
if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemDetails.CpuModel = info[0].ModelName
a.systemInfo.CpuModel = info[0].ModelName
}
// cores / threads
cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU
if threads == 0 {
threads, _ = cpu.Counts(true)
}
// in lxc, logical cores reflects container limits, so use that as cores if lower
if threads > 0 && threads < cores {
cores = threads
}
a.systemDetails.Cores = cores
a.systemDetails.Threads = threads
// total memory
a.systemDetails.MemoryTotal = hostInfo.MemTotal
if a.systemDetails.MemoryTotal == 0 {
if v, err := mem.VirtualMemory(); err == nil {
a.systemDetails.MemoryTotal = v.Total
a.systemInfo.Cores, _ = cpu.Counts(false)
if threads, err := cpu.Counts(true); err == nil {
if threads > 0 && threads < a.systemInfo.Cores {
// in lxc logical cores reflects container limits, so use that as cores if lower
a.systemInfo.Cores = threads
} else {
a.systemInfo.Threads = threads
}
}
@@ -139,6 +98,15 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
slog.Error("Error getting cpu metrics", "err", err)
}
if topProcess, err := getTopCpuProcess(cacheTimeMs); err == nil {
if topProcess != nil {
topProcess.Percent = twoDecimals(topProcess.Percent)
systemStats.TopCpuProcess = topProcess
}
} else {
slog.Error("Error getting top cpu process", "err", err)
}
// per-core cpu usage
if perCoreUsage, err := getPerCoreCpuUsage(cacheTimeMs); err == nil {
systemStats.CpuCoresUsage = perCoreUsage
@@ -236,16 +204,21 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
}
// update system info
// update base system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu
a.systemInfo.LoadAvg = systemStats.LoadAvg
// TODO: remove these in future release in favor of load avg array
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime()
// TODO: in future release, remove MB bandwidth values in favor of bytes
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads
slog.Debug("sysinfo", "data", a.systemInfo)
return systemStats
}
@@ -275,24 +248,3 @@ func getARCSize() (uint64, error) {
return 0, fmt.Errorf("failed to parse size field")
}
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
func getOsPrettyName() (string, error) {
file, err := os.Open("/etc/os-release")
if err != nil {
return "", err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
value := after
value = strings.Trim(value, `"`)
return value, nil
}
}
return "", errors.New("pretty name not found")
}

View File

@@ -1,292 +0,0 @@
//go:build linux
package agent
import (
"context"
"errors"
"log/slog"
"maps"
"math"
"os"
"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
}
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts.
func isSystemdAvailable() bool {
if _, err := os.Stat("/run/systemd/system"); err == nil {
return true
}
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
return strings.TrimSpace(string(data)) == "systemd"
}
return false
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
if skipSystemd, _ := GetEnv("SKIP_SYSTEMD"); skipSystemd == "true" {
return nil, nil
}
// Check if systemd is available on the system before attempting connection
if !isSystemdAvailable() {
slog.Debug("Systemd not available on this system")
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
}

View File

@@ -1,38 +0,0 @@
//go:build !linux
package agent
import (
"errors"
"github.com/henrygd/beszel/internal/entities/systemd"
)
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
}
// newSystemdManager creates a new systemdManager.
func newSystemdManager() (*systemdManager, error) {
return &systemdManager{}, nil
}
// getServiceStats returns nil for non-linux systems.
func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Service {
return nil
}
// getServiceStatsCount returns 0 for non-linux systems.
func (sm *systemdManager) getServiceStatsCount() int {
return 0
}
// getFailedServiceCount returns 0 for non-linux systems.
func (sm *systemdManager) getFailedServiceCount() uint16 {
return 0
}
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}

View File

@@ -1,53 +0,0 @@
//go:build !linux && testing
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewSystemdManager(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
assert.NotNil(t, manager)
}
func TestSystemdManagerGetServiceStats(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// Test with refresh = true
result := manager.getServiceStats(true)
assert.Nil(t, result)
// Test with refresh = false
result = manager.getServiceStats(false)
assert.Nil(t, result)
}
func TestSystemdManagerGetServiceDetails(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
result, err := manager.getServiceDetails("any-service")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
// Test with empty service name
result, err = manager.getServiceDetails("")
assert.Error(t, err)
assert.Equal(t, "systemd manager unavailable", err.Error())
assert.Nil(t, result)
}
func TestSystemdManagerFields(t *testing.T) {
manager, err := newSystemdManager()
assert.NoError(t, err)
// The non-linux manager should be a simple struct with no special fields
// We can't test private fields directly, but we can test the methods work
assert.NotNil(t, manager)
}

View File

@@ -1,188 +0,0 @@
//go:build linux && testing
package agent
import (
"os"
"strings"
"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 TestIsSystemdAvailable(t *testing.T) {
// Note: This test's result will vary based on the actual system running the tests
// On systems with systemd, it should return true
// On systems without systemd, it should return false
result := isSystemdAvailable()
// Check if either the /run/systemd/system directory exists or PID 1 is systemd
runSystemdExists := false
if _, err := os.Stat("/run/systemd/system"); err == nil {
runSystemdExists = true
}
pid1IsSystemd := false
if data, err := os.ReadFile("/proc/1/comm"); err == nil {
pid1IsSystemd = strings.TrimSpace(string(data)) == "systemd"
}
expected := runSystemdExists || pid1IsSystemd
assert.Equal(t, expected, result, "isSystemdAvailable should correctly detect systemd presence")
// Log the result for informational purposes
if result {
t.Log("Systemd is available on this system")
} else {
t.Log("Systemd is not available on this system")
}
}
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")
}
})
}
}

View File

@@ -1,17 +0,0 @@
{
"ID": "7TRN:IPZB:QYBB:VPBQ:UMPP:KARE:6ZNR:XE6T:7EWV:PKF4:ZOJD:TPYS",
"Containers": 14,
"ContainersRunning": 3,
"ContainersPaused": 1,
"ContainersStopped": 10,
"Images": 508,
"Driver": "overlay2",
"KernelVersion": "6.8.0-31-generic",
"OperatingSystem": "Ubuntu 24.04 LTS",
"OSVersion": "24.04",
"OSType": "linux",
"Architecture": "x86_64",
"NCPU": 4,
"MemTotal": 2095882240,
"ServerVersion": "27.0.1"
}

View File

@@ -1,130 +0,0 @@
package main
import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"hash"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Download smartctl.exe from the given URL and save it to the given destination.
// This is used to embed smartctl.exe in the Windows build.
func main() {
url := flag.String("url", "", "URL to download smartctl.exe from (required)")
out := flag.String("out", "", "Destination path for smartctl.exe (required)")
sha := flag.String("sha", "", "Optional SHA1/SHA256 checksum for integrity validation")
force := flag.Bool("force", false, "Force re-download even if destination exists")
flag.Parse()
if *url == "" || *out == "" {
fatalf("-url and -out are required")
}
if !*force {
if info, err := os.Stat(*out); err == nil && info.Size() > 0 {
fmt.Println("smartctl.exe already present, skipping download")
return
}
}
if err := downloadFile(*url, *out, *sha); err != nil {
fatalf("download failed: %v", err)
}
}
func downloadFile(url, dest, shaHex string) error {
// Prepare destination
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create dir: %w", err)
}
// HTTP client
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", "beszel-fetchsmartctl/1.0")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
tmp := dest + ".tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open tmp: %w", err)
}
// Determine hash algorithm based on length (SHA1=40, SHA256=64)
var hasher hash.Hash
if shaHex := strings.TrimSpace(shaHex); shaHex != "" {
cleanSha := strings.ToLower(strings.ReplaceAll(shaHex, " ", ""))
switch len(cleanSha) {
case 40:
hasher = sha1.New()
case 64:
hasher = sha256.New()
default:
f.Close()
os.Remove(tmp)
return fmt.Errorf("unsupported hash length: %d (expected 40 for SHA1 or 64 for SHA256)", len(cleanSha))
}
}
var mw io.Writer = f
if hasher != nil {
mw = io.MultiWriter(f, hasher)
}
if _, err := io.Copy(mw, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("write tmp: %w", err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close tmp: %w", err)
}
if hasher != nil && shaHex != "" {
cleanSha := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(shaHex), " ", ""))
got := strings.ToLower(hex.EncodeToString(hasher.Sum(nil)))
if got != cleanSha {
os.Remove(tmp)
return fmt.Errorf("hash mismatch: got %s want %s", got, cleanSha)
}
}
// Make executable and move into place
if err := os.Chmod(tmp, 0o755); err != nil {
os.Remove(tmp)
return fmt.Errorf("chmod: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
fmt.Println("smartctl.exe downloaded to", dest)
return nil
}
func fatalf(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.18.0-beta.2"
Version = "0.15.3"
// AppName is the name of the application.
AppName = "beszel"
)

34
go.mod
View File

@@ -1,27 +1,24 @@
module github.com/henrygd/beszel
go 1.25.5
go 1.25.3
require (
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/ebitengine/purego v0.9.1
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.12.1
github.com/nicholas-fedor/shoutrrr v0.11.1
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.34.0
github.com/pocketbase/pocketbase v0.31.0
github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.45.0
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
golang.org/x/sys v0.38.0
golang.org/x/crypto v0.43.0
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,13 +30,13 @@ require (
github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ganigeorgiev/fexpr v0.5.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-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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
@@ -50,19 +47,20 @@ require (
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.33.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/image v0.32.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
modernc.org/sqlite v1.39.1 // indirect
)

80
go.sum
View File

@@ -9,8 +9,6 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/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/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/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=
@@ -25,8 +23,8 @@ 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/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/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -51,15 +49,13 @@ 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-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/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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -83,8 +79,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.12.1 h1:8NjY+I3K7cGHy89ncnaPGUA0ex44XbYK3SAFJX9YMI8=
github.com/nicholas-fedor/shoutrrr v0.12.1/go.mod h1:64qWuPpvTUv9ZppEoR6OdroiFmgf9w11YSaR0h9KZGg=
github.com/nicholas-fedor/shoutrrr v0.11.1 h1:DND1gW8UM8GYG8c0bUZ5fPFAnm3id8noPdfaFBUmezk=
github.com/nicholas-fedor/shoutrrr v0.11.1/go.mod h1:RZuSZSEaSimS47zTOLXb6HJDwLjDHiuJ9SrzxsDcWaQ=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
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=
@@ -94,8 +90,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.34.0 h1:5W80PrGvkRYIMAIK90F7w031/hXgZVz1KSuCJqSpgJo=
github.com/pocketbase/pocketbase v0.34.0/go.mod h1:K/9z/Zb9PR9yW2Qyoc73jHV/EKT8cMTk9bQWyrzYlvI=
github.com/pocketbase/pocketbase v0.31.0 h1:JaOtSDytdA+a0r4689Mrjda4rmq+BaHgEJkPeOIydms=
github.com/pocketbase/pocketbase v0.31.0/go.mod h1:p4a83n+DlBcTvvqhC7QDy0KDmQ2la2c6dgxdIBWwKiE=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -116,10 +112,10 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -127,44 +123,44 @@ github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQ
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/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-20190916202348-b4ddaad3f8a3/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -191,8 +187,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
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/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -28,7 +28,6 @@ type AlertManager struct {
type AlertMessageData struct {
UserID string
SystemID string
Title string
Message string
Link string
@@ -41,19 +40,13 @@ type UserNotificationSettings struct {
}
type SystemAlertStats struct {
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
GPU map[string]SystemAlertGPUData `json:"g"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
Battery [2]uint8 `json:"bat"`
}
type SystemAlertGPUData struct {
Usage float64 `json:"u"`
Cpu float64 `json:"cpu"`
Mem float64 `json:"mp"`
Disk float64 `json:"dp"`
NetSent float64 `json:"ns"`
NetRecv float64 `json:"nr"`
Temperatures map[string]float32 `json:"t"`
LoadAvg [3]float64 `json:"la"`
}
type SystemAlertData struct {
@@ -79,6 +72,7 @@ var supportsTitle = map[string]struct{}{
"ifttt": {},
"join": {},
"lark": {},
"matrix": {},
"ntfy": {},
"opsgenie": {},
"pushbullet": {},
@@ -105,84 +99,10 @@ func NewAlertManager(app hubLike) *AlertManager {
func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
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
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
record, err := am.hub.FindFirstRecordByFilter(
"user_settings", "user={:user}",

View File

@@ -1,387 +0,0 @@
//go:build testing
// +build testing
package alerts_test
import (
"encoding/json"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
func TestBatteryAlertLogic(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20, // threshold: 20%
"min": 1, // 1 minute (immediate trigger for testing)
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
// Create system stats with battery at 50% (above threshold - should NOT trigger)
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery, discharging
}
statsHighJSON, _ := json.Marshal(statsHigh)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
// Create CombinedData for the alert handler
combinedDataHigh := &system.CombinedData{
Stats: statsHigh,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with high battery
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when battery (50%%) is above threshold (20%%)")
// Now create stats with battery at 15% (below threshold - should trigger)
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery, discharging
}
statsLowJSON, _ := json.Marshal(statsLow)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
combinedDataLow := &system.CombinedData{
Stats: statsLow,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with low battery
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (battery 15% is below threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"), "Alert SHOULD be triggered when battery (15%%) drops below threshold (20%%)")
// Now test resolution: battery goes back above threshold
statsRecovered := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{25, 1}, // 25% battery, discharging
}
statsRecoveredJSON, _ := json.Marshal(statsRecovered)
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsRecoveredJSON),
})
require.NoError(t, err)
combinedDataRecovered := &system.CombinedData{
Stats: statsRecovered,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with recovered battery
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert is now resolved (battery 25% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should be resolved when battery (25%%) goes above threshold (20%%)")
}
// TestBatteryAlertNoBattery verifies that systems without battery data don't trigger alerts
func TestBatteryAlertNoBattery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 20,
"min": 1,
})
require.NoError(t, err)
// Create stats with NO battery data (Battery[0] = 0)
statsNoBattery := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{0, 0}, // No battery
}
combinedData := &system.CombinedData{
Stats: statsNoBattery,
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts
am := hub.GetAlertManager()
err = am.HandleSystemAlerts(systemRecord, combinedData)
require.NoError(t, err)
// Wait a moment for processing
time.Sleep(20 * time.Millisecond)
// Verify alert is NOT triggered (no battery data should skip the alert)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should NOT be triggered when system has no battery")
}
// TestBatteryAlertAveragedSamples tests battery alerts with min > 1 (averaging multiple samples)
// This ensures the inverted threshold logic works correctly across averaged time windows
func TestBatteryAlertAveragedSamples(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
// Create a battery alert with threshold of 25% and min of 2 minutes (requires averaging)
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Battery",
"system": systemRecord.Id,
"user": user.Id,
"value": 25, // threshold: 25%
"min": 2, // 2 minutes - requires averaging
})
require.NoError(t, err)
// Verify alert is not triggered initially
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
am := hub.GetAlertManager()
now := time.Now().UTC()
// Create system_stats records with low battery (below threshold)
// The alert has min=2 minutes, so alert.time = now - 2 minutes
// For the alert to be valid, alert.time must be AFTER the oldest record's created time
// So we need records older than (now - 2 min), plus records within the window
// Records at: now-3min (oldest, before window), now-90s, now-60s, now-30s
recordTimes := []time.Duration{
-180 * time.Second, // 3 min ago - this makes the oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimes {
statsLow := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1}, // 15% battery (below 25% threshold)
}
statsLowJSON, _ := json.Marshal(statsLow)
recordTime := now.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsLowJSON),
})
require.NoError(t, err)
// Update created time to simulate historical records - use SetRaw with formatted string
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with low battery
combinedDataLow := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{15, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should trigger because average battery is below threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (average battery 15% is below threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.True(t, batteryAlert.GetBool("triggered"),
"Alert SHOULD be triggered when average battery (15%%) is below threshold (25%%) over min period")
// Now add records with high battery to test resolution
// Use a new time window 2 minutes later
newNow := now.Add(2 * time.Minute)
// Records need to span before the alert time window (newNow - 2 min)
recordTimesHigh := []time.Duration{
-180 * time.Second, // 3 min before newNow - makes oldest record before alert.time
-90 * time.Second,
-60 * time.Second,
-30 * time.Second,
}
for _, offset := range recordTimesHigh {
statsHigh := system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1}, // 50% battery (above 25% threshold)
}
statsHighJSON, _ := json.Marshal(statsHigh)
recordTime := newNow.Add(offset)
record, err := beszelTests.CreateRecord(hub, "system_stats", map[string]any{
"system": systemRecord.Id,
"type": "1m",
"stats": string(statsHighJSON),
})
require.NoError(t, err)
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
err = hub.SaveNoValidate(record)
require.NoError(t, err)
}
// Create combined data with high battery
combinedDataHigh := &system.CombinedData{
Stats: system.Stats{
Cpu: 10,
MemPct: 30,
DiskPct: 40,
Battery: [2]uint8{50, 1},
},
Info: system.Info{
AgentVersion: "0.12.0",
Cpu: 10,
MemPct: 30,
DiskPct: 40,
},
}
// Update system timestamp to the new time window
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should resolve because average battery is now above threshold
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert is resolved (average battery 50% is above threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
require.NoError(t, err)
assert.False(t, batteryAlert.GetBool("triggered"),
"Alert should be resolved when average battery (50%%) is above threshold (25%%) over min period")
}

View File

@@ -1,426 +0,0 @@
//go:build testing
// +build testing
package alerts_test
import (
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/stretchr/testify/assert"
)
func TestAlertSilencedOneTime(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Create an alert
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 1,
})
assert.NoError(t, err)
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Test that alert is silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced during active one-time window")
// Create a window that has already ended
pastStart := now.Add(-3 * time.Hour)
pastEnd := now.Add(-2 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": pastStart,
"end": pastEnd,
})
assert.NoError(t, err)
// Should still be silenced because of the first window
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
// Clear all windows and create a future window
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
futureStart := now.Add(2 * time.Hour)
futureEnd := now.Add(3 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": futureStart,
"end": futureEnd,
})
assert.NoError(t, err)
// Alert should NOT be silenced (window hasn't started yet)
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
_ = alert
}
func TestAlertSilencedDaily(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Get current hour and create a window that includes current time
now := time.Now().UTC()
currentHour := now.Hour()
currentMin := now.Minute()
// Create a window from 1 hour ago to 1 hour from now
startHour := (currentHour - 1 + 24) % 24
endHour := (currentHour + 1) % 24
// Create times with just the hours/minutes we want (date doesn't matter for daily)
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Alert should be silenced (current time is within the daily window)
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced during active daily window")
// Clear windows and create one that doesn't include current time
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
// Create a window from 6-12 hours from now
futureStartHour := (currentHour + 6) % 24
futureEndHour := (currentHour + 12) % 24
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Alert should NOT be silenced
silenced = am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
}
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a window that crosses midnight: 22:00 - 02:00
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "daily",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Test with a time at 23:00 (should be silenced)
// We can't control the actual current time, but we can verify the logic
// by checking if the window was created correctly
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
"user": user.Id,
"system": system.Id,
})
assert.NoError(t, err)
assert.Len(t, windows, 1, "Should have created 1 window")
window := windows[0]
assert.Equal(t, "daily", window.GetString("type"))
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
}
func TestAlertSilencedGlobal(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create multiple systems
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a global quiet hours window (no system specified)
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
// system field is empty/null for global windows
})
assert.NoError(t, err)
// All systems should be silenced
for _, system := range systems {
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
}
// Even with a systemID that doesn't exist, should be silenced
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
}
func TestAlertSilencedSystemSpecific(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create multiple systems
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
assert.NoError(t, err)
system1 := systems[0]
system2 := systems[1]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a system-specific quiet hours window for system1 only
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system1.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// System1 should be silenced
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
assert.True(t, silenced, "Alert should be silenced for system1")
// System2 should NOT be silenced
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
assert.False(t, silenced, "Alert should not be silenced for system2")
}
func TestAlertSilencedMultiUser(t *testing.T) {
hub, _ := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create two users
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
assert.NoError(t, err)
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
assert.NoError(t, err)
// Create a system accessible to both users
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Create a quiet hours window for user1 only
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user1.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// User1 should be silenced
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
assert.True(t, silenced, "Alert should be silenced for user1")
// User2 should NOT be silenced
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced for user2")
}
func TestAlertSilencedWithActualAlert(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Create a status alert
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status",
"system": system.Id,
"user": user.Id,
"min": 1,
})
assert.NoError(t, err)
// Create user settings with email
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
if err != nil || userSettings == nil {
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user.Id,
"settings": map[string]any{
"emails": []string{"test@example.com"},
},
})
assert.NoError(t, err)
}
// Create a quiet hours window
now := time.Now().UTC()
startTime := now.Add(-1 * time.Hour)
endTime := now.Add(1 * time.Hour)
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
"user": user.Id,
"system": system.Id,
"type": "one-time",
"start": startTime,
"end": endTime,
})
assert.NoError(t, err)
// Get initial email count
initialEmailCount := hub.TestMailer.TotalSend()
// Trigger an alert by setting system to down
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
// Wait for the alert to be processed (1 minute + buffer)
time.Sleep(time.Second * 75)
synctest.Wait()
// Check that no email was sent (because alert is silenced)
finalEmailCount := hub.TestMailer.TotalSend()
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
// Clear quiet hours windows
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
assert.NoError(t, err)
// Reset system to up, then down again
system.Set("status", "up")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
time.Sleep(100 * time.Millisecond)
system.Set("status", "down")
err = hub.SaveNoValidate(system)
assert.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(time.Second * 75)
synctest.Wait()
// Now an email should be sent
newEmailCount := hub.TestMailer.TotalSend()
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
})
}
func TestAlertSilencedNoWindows(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
assert.NoError(t, err)
system := systems[0]
// Get alert manager
am := alerts.NewAlertManager(hub)
defer am.StopWorker()
// Without any quiet hours windows, alert should NOT be silenced
silenced := am.IsNotificationSilenced(user.Id, system.Id)
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
}

View File

@@ -1,67 +0,0 @@
package alerts
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// handleSmartDeviceAlert sends alerts when a SMART device state changes from PASSED to FAILED.
// This is automatic and does not require user opt-in.
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
oldState := e.Record.Original().GetString("state")
newState := e.Record.GetString("state")
// Only alert when transitioning from PASSED to FAILED
if oldState != "PASSED" || newState != "FAILED" {
return e.Next()
}
systemID := e.Record.GetString("system")
if systemID == "" {
return e.Next()
}
// Fetch the system record to get the name and users
systemRecord, err := e.App.FindRecordById("systems", systemID)
if err != nil {
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
return e.Next()
}
systemName := systemRecord.GetString("name")
deviceName := e.Record.GetString("name")
model := e.Record.GetString("model")
// Build alert message
title := fmt.Sprintf("SMART failure on %s: %s \U0001F534", systemName, deviceName)
var message string
if model != "" {
message = fmt.Sprintf("Disk %s (%s) SMART status changed to FAILED", deviceName, model)
} else {
message = fmt.Sprintf("Disk %s SMART status changed to FAILED", deviceName)
}
// Get users associated with the system
userIDs := systemRecord.GetStringSlice("users")
if len(userIDs) == 0 {
return e.Next()
}
// Send alert to each user
for _, userID := range userIDs {
if err := am.SendAlert(AlertMessageData{
UserID: userID,
SystemID: systemID,
Title: title,
Message: message,
Link: am.hub.MakeLink("system", systemID),
LinkText: "View " + systemName,
}); err != nil {
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
}
}
return e.Next()
}

View File

@@ -1,196 +0,0 @@
//go:build testing
// +build testing
package alerts_test
import (
"testing"
"time"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/assert"
)
func TestSmartDeviceAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sda",
"model": "Samsung SSD 970 EVO",
"state": "PASSED",
})
assert.NoError(t, err)
// Verify no emails sent initially
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(50 * time.Millisecond)
// Verify that an email was sent
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
// Check the email content
lastMessage := hub.TestMailer.LastMessage()
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
assert.Contains(t, lastMessage.Subject, "/dev/sda")
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
assert.Contains(t, lastMessage.Text, "FAILED")
}
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state UNKNOWN
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sda",
"model": "Samsung SSD 970 EVO",
"state": "UNKNOWN",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the state from UNKNOWN to FAILED - should NOT trigger alert
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify no email was sent (only PASSED -> FAILED triggers alert)
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
// Re-fetch the record again
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update state from FAILED to PASSED - should NOT trigger alert
smartDevice.Set("state", "PASSED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify no email was sent
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
}
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a second user
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
assert.NoError(t, err)
// Create user settings for the second user
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user2.Id,
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
})
assert.NoError(t, err)
// Create a system with both users
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "shared-system",
"users": []string{user1.Id, user2.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/nvme0n1",
"model": "WD Black SN850",
"state": "PASSED",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify that two emails were sent (one for each user)
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
}
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
// Create a system for the user
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
assert.NoError(t, err)
// Create a smart_device with state PASSED but no model
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
"system": system.Id,
"name": "/dev/sdb",
"state": "PASSED",
})
assert.NoError(t, err)
// Re-fetch the record so PocketBase can properly track original values
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
assert.NoError(t, err)
// Update the smart device state to FAILED
smartDevice.Set("state", "FAILED")
err = hub.Save(smartDevice)
assert.NoError(t, err)
time.Sleep(50 * time.Millisecond)
// Verify that an email was sent
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
// Check that the email doesn't have empty parentheses for missing model
lastMessage := hub.TestMailer.LastMessage()
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
assert.Contains(t, lastMessage.Text, "/dev/sdb")
}

View File

@@ -161,15 +161,19 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
message := strings.TrimSuffix(title, emoji)
// Get system ID for the link
systemID := alertRecord.GetString("system")
// if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
// return errs["user"]
// }
// user := alertRecord.ExpandedOne("user")
// if user == nil {
// return nil
// }
return am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
SystemID: systemID,
Title: title,
Message: message,
Link: am.hub.MakeLink("system", systemID),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}

View File

@@ -64,32 +64,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
case "LoadAvg15":
val = data.Info.LoadAvg[2]
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")
threshold := alertRecord.GetFloat("value")
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(name)
// CONTINUE
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
if lowAlert {
if (!triggered && val >= threshold) || (triggered && val < threshold) {
continue
}
} else {
if (!triggered && val <= threshold) || (triggered && val > threshold) {
continue
}
// IF alert is not triggered and curValue is less than threshold
// OR alert is triggered and curValue is greater than threshold
if (!triggered && val <= threshold) || (triggered && val > threshold) {
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
continue
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
@@ -107,11 +92,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// send alert immediately if min is 1 - no need to sum up values.
if min == 1 {
if lowAlert {
alert.triggered = val < threshold
} else {
alert.triggered = val > threshold
}
alert.triggered = val > threshold
go am.sendSystemAlert(alert)
continue
}
@@ -225,19 +206,6 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
alert.val += stats.LoadAvg[1]
case "LoadAvg15":
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:
continue
}
@@ -275,24 +243,12 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
// pass through alert if count is greater than or equal to minCount
if float32(alert.count) >= minCount {
// Battery alert has inverted logic: trigger when value is BELOW threshold
lowAlert := isLowAlert(alert.name)
if lowAlert {
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)
}
} 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)
}
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendSystemAlert(alert)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendSystemAlert(alert)
}
}
}
@@ -312,26 +268,17 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
alert.name = after + "m Load"
}
// make title alert name lowercase if not CPU or GPU
// make title alert name lowercase if not CPU
titleAlertName := alert.name
if titleAlertName != "CPU" && titleAlertName != "GPU" {
if titleAlertName != "CPU" {
titleAlertName = strings.ToLower(titleAlertName)
}
var subject string
lowAlert := isLowAlert(alert.name)
if alert.triggered {
if lowAlert {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
}
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
if lowAlert {
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
} else {
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
}
minutesLabel := "minute"
if alert.min > 1 {
@@ -349,14 +296,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
}
am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"),
SystemID: alert.systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
Link: am.hub.MakeLink("system", systemName),
LinkText: "View " + systemName,
})
}
func isLowAlert(name string) bool {
return name == "Battery"
}

View File

@@ -17,8 +17,9 @@ import (
type cmdOptions struct {
key string // key is the public key(s) for SSH authentication.
listen string // listen is the address or port to listen on.
hubURL string // hubURL is the URL of the Beszel hub.
token string // token is the token to use for authentication.
// TODO: add hubURL and token
// hubURL string // hubURL is the URL of the hub to use.
// token string // token is the token to use for authentication.
}
// parse parses the command line flags and populates the config struct.
@@ -46,13 +47,13 @@ func (opts *cmdOptions) parse() bool {
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
pflag.StringVarP(&opts.hubURL, "url", "u", "", "URL of the Beszel hub")
pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
// pflag.StringVarP(&opts.token, "token", "t", "", "Token to use for authentication")
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
help := pflag.BoolP("help", "h", false, "Show this help message")
// Convert old single-dash long flags to double-dash for backward compatibility
flagsToConvert := []string{"key", "listen", "url", "token"}
flagsToConvert := []string{"key", "listen"}
for i, arg := range os.Args {
for _, flag := range flagsToConvert {
singleDash := "-" + flag
@@ -94,13 +95,6 @@ func (opts *cmdOptions) parse() bool {
return true
}
// Set environment variables from CLI flags (if provided)
if opts.hubURL != "" {
os.Setenv("HUB_URL", opts.hubURL)
}
if opts.token != "" {
os.Setenv("TOKEN", opts.token)
}
return false
}

View File

@@ -1,10 +1,8 @@
package common
import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
)
type WebSocketAction = uint8
@@ -20,8 +18,6 @@ const (
GetContainerInfo
// Request SMART data from agent
GetSmartData
// Request detailed systemd service info from agent
GetSystemdInfo
// Add new actions here...
)
@@ -35,14 +31,13 @@ type HubRequest[T any] struct {
// AgentResponse defines the structure for responses sent from agent to hub.
type AgentResponse struct {
Id *uint32 `cbor:"0,keyasint,omitempty"`
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
Error string `cbor:"3,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
// Data is the generic response payload for new endpoints (0.18+)
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
String *string `cbor:"4,keyasint,omitempty,omitzero"`
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
}
type FingerprintRequest struct {
@@ -59,8 +54,8 @@ type FingerprintResponse struct {
}
type DataRequestOptions struct {
CacheTimeMs uint16 `cbor:"0,keyasint"`
IncludeDetails bool `cbor:"1,keyasint"`
CacheTimeMs uint16 `cbor:"0,keyasint"`
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
}
type ContainerLogsRequest struct {
@@ -70,7 +65,3 @@ type ContainerLogsRequest struct {
type ContainerInfoRequest struct {
ContainerID string `cbor:"0,keyasint"`
}
type SystemdInfoRequest struct {
ServiceName string `cbor:"0,keyasint"`
}

View File

@@ -17,7 +17,7 @@ RUN rm -rf /tmp/*
# --------------------------
# Final image: default scratch-based agent
# --------------------------
FROM alpine:3.22
FROM alpine:latest
COPY --from=builder /agent /agent
RUN apk add --no-cache smartmontools

View File

@@ -16,7 +16,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
# Final image
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
# --------------------------
FROM alpine:3.22
FROM alpine:edge
COPY --from=builder /agent /agent

View File

@@ -34,14 +34,6 @@ type ApiStats struct {
MemoryStats MemoryStats `json:"memory_stats"`
}
// Docker system info from /info API endpoint
type HostInfo struct {
OperatingSystem string `json:"OperatingSystem"`
KernelVersion string `json:"KernelVersion"`
NCPU int `json:"NCPU"`
MemTotal uint64 `json:"MemTotal"`
}
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/systemd"
)
type Stats struct {
@@ -48,6 +47,7 @@ type Stats struct {
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..]
TopCpuProcess *TopCpuProcess `json:"tcp,omitempty" cbor:"35,keyasint,omitempty"`
}
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
@@ -123,56 +123,39 @@ const (
ConnectionTypeWebSocket
)
// Core system data that is needed in All Systems table
type Info struct {
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
Cores int `json:"c,omitzero" cbor:"2,keyasint,omitzero"` // deprecated - moved to Details struct
// Threads is needed in Info struct to calculate load average thresholds
Hostname string `json:"h" cbor:"0,keyasint"`
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
Cores int `json:"c" cbor:"2,keyasint"`
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
CpuModel string `json:"m" cbor:"4,keyasint"`
Uptime uint64 `json:"u" cbor:"5,keyasint"`
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
MemPct float64 `json:"mp" cbor:"7,keyasint"`
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
AgentVersion string `json:"v" cbor:"10,keyasint"`
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
Os Os `json:"os" cbor:"14,keyasint"`
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery [2]uint8 `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
}
// Data that does not change during process lifetime and is not needed in All Systems table
type Details struct {
Hostname string `cbor:"0,keyasint"`
Kernel string `cbor:"1,keyasint,omitempty"`
Cores int `cbor:"2,keyasint"`
Threads int `cbor:"3,keyasint"`
CpuModel string `cbor:"4,keyasint"`
Os Os `cbor:"5,keyasint"`
OsName string `cbor:"6,keyasint"`
Arch string `cbor:"7,keyasint"`
Podman bool `cbor:"8,keyasint,omitempty"`
MemoryTotal uint64 `cbor:"9,keyasint"`
SmartInterval time.Duration `cbor:"10,keyasint,omitempty"`
// TODO: remove load fields in future release in favor of load avg array
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
}
// Final data structure to return to the hub
type CombinedData struct {
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
Details *Details `cbor:"4,keyasint,omitempty"`
Stats Stats `json:"stats" cbor:"0,keyasint"`
Info Info `json:"info" cbor:"1,keyasint"`
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
}
type TopCpuProcess struct {
Name string `json:"n" cbor:"0,keyasint"`
Percent float64 `json:"p" cbor:"1,keyasint"`
}

View File

@@ -1,127 +0,0 @@
package systemd
import (
"math"
"runtime"
"time"
)
// ServiceState represents the status of a systemd service
type ServiceState uint8
const (
StatusActive ServiceState = iota
StatusInactive
StatusFailed
StatusActivating
StatusDeactivating
StatusReloading
)
// ServiceSubState represents the sub status of a systemd service
type ServiceSubState uint8
const (
SubStateDead ServiceSubState = iota
SubStateRunning
SubStateExited
SubStateFailed
SubStateUnknown
)
// ParseServiceStatus converts a string status to a ServiceStatus enum value
func ParseServiceStatus(status string) ServiceState {
switch status {
case "active":
return StatusActive
case "inactive":
return StatusInactive
case "failed":
return StatusFailed
case "activating":
return StatusActivating
case "deactivating":
return StatusDeactivating
case "reloading":
return StatusReloading
default:
return StatusInactive
}
}
// ParseServiceSubState converts a string sub status to a ServiceSubState enum value
func ParseServiceSubState(subState string) ServiceSubState {
switch subState {
case "dead":
return SubStateDead
case "running":
return SubStateRunning
case "exited":
return SubStateExited
case "failed":
return SubStateFailed
default:
return SubStateUnknown
}
}
// Service represents a single systemd service with its stats.
type Service struct {
Name string `json:"n" cbor:"0,keyasint"`
State ServiceState `json:"s" cbor:"1,keyasint"`
Cpu float64 `json:"c" cbor:"2,keyasint"`
Mem uint64 `json:"m" cbor:"3,keyasint"`
MemPeak uint64 `json:"mp" cbor:"4,keyasint"`
Sub ServiceSubState `json:"ss" cbor:"5,keyasint"`
CpuPeak float64 `json:"cp" cbor:"6,keyasint"`
PrevCpuUsage uint64 `json:"-"`
PrevReadTime time.Time `json:"-"`
}
// UpdateCPUPercent calculates the CPU usage percentage for the service.
func (s *Service) UpdateCPUPercent(cpuUsage uint64) {
now := time.Now()
if s.PrevReadTime.IsZero() || cpuUsage < s.PrevCpuUsage {
s.Cpu = 0
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
duration := now.Sub(s.PrevReadTime).Nanoseconds()
if duration <= 0 {
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
return
}
coreCount := int64(runtime.NumCPU())
duration *= coreCount
usageDelta := cpuUsage - s.PrevCpuUsage
cpuPercent := float64(usageDelta) / float64(duration)
s.Cpu = twoDecimals(cpuPercent * 100)
if s.Cpu > s.CpuPeak {
s.CpuPeak = s.Cpu
}
s.PrevCpuUsage = cpuUsage
s.PrevReadTime = now
}
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
// ServiceDependency represents a unit that the service depends on.
type ServiceDependency struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
ActiveState string `json:"activeState,omitempty"`
SubState string `json:"subState,omitempty"`
}
// ServiceDetails contains extended information about a systemd service.
type ServiceDetails map[string]any

View File

@@ -1,113 +0,0 @@
//go:build testing
package systemd_test
import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
func TestParseServiceStatus(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceState
}{
{"active", systemd.StatusActive},
{"inactive", systemd.StatusInactive},
{"failed", systemd.StatusFailed},
{"activating", systemd.StatusActivating},
{"deactivating", systemd.StatusDeactivating},
{"reloading", systemd.StatusReloading},
{"unknown", systemd.StatusInactive}, // default case
{"", systemd.StatusInactive}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceStatus(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestParseServiceSubState(t *testing.T) {
tests := []struct {
input string
expected systemd.ServiceSubState
}{
{"dead", systemd.SubStateDead},
{"running", systemd.SubStateRunning},
{"exited", systemd.SubStateExited},
{"failed", systemd.SubStateFailed},
{"unknown", systemd.SubStateUnknown},
{"other", systemd.SubStateUnknown}, // default case
{"", systemd.SubStateUnknown}, // default case
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
result := systemd.ParseServiceSubState(test.input)
assert.Equal(t, test.expected, result)
})
}
}
func TestServiceUpdateCPUPercent(t *testing.T) {
t.Run("initial call sets CPU to 0", func(t *testing.T) {
service := &systemd.Service{}
service.UpdateCPUPercent(1000)
assert.Equal(t, 0.0, service.Cpu)
assert.Equal(t, uint64(1000), service.PrevCpuUsage)
assert.False(t, service.PrevReadTime.IsZero())
})
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
// CPU usage should be positive and reasonable
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
})
t.Run("CPU peak updates only when higher", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
initialPeak := service.CpuPeak
// Now try with much lower CPU usage - should not update peak
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(1000000) // Much lower usage
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
})
t.Run("handles zero duration", func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
now := time.Now()
service.PrevReadTime = now
// Mock time.Now() to return the same time to ensure zero duration
// Since we can't mock time in Go easily, we'll check the logic manually
// The zero duration case happens when duration <= 0
assert.Equal(t, 0.0, service.Cpu, "CPU should start at 0")
})
t.Run("handles CPU usage wraparound", func(t *testing.T) {
service := &systemd.Service{}
// Simulate wraparound where new usage is less than previous
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(500) // Less than previous, should reset
assert.Equal(t, 0.0, service.Cpu)
})
}

View File

@@ -415,11 +415,7 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Wait for first value to expire
time.Sleep(time.Millisecond * 20)
// Trigger lazy cleanup of the expired key
_, ok := em.GetOk("key1")
assert.False(t, ok)
// Try to remove the remaining "value1" entry (key3)
// Try to remove the expired value - should remove one of the "value1" entries
removedValue, ok := em.RemovebyValue("value1")
assert.True(t, ok)
assert.Equal(t, "value1", removedValue)
@@ -427,9 +423,14 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
// Should still have key2 (different value)
assert.True(t, em.Has("key2"))
// key1 should be gone due to expiration and key3 should be removed by value.
assert.False(t, em.Has("key1"))
assert.False(t, em.Has("key3"))
// Should have removed one of the "value1" entries (either key1 or key3)
// But we can't predict which one due to map iteration order
key1Exists := em.Has("key1")
key3Exists := em.Has("key3")
// Exactly one of key1 or key3 should be gone
assert.False(t, key1Exists && key3Exists) // Both shouldn't exist
assert.True(t, key1Exists || key3Exists) // At least one should still exist
}
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {

View File

@@ -268,10 +268,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// get SMART data
apiAuth.GET("/smart", h.getSmartData)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -344,46 +342,22 @@ func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
}, "info")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
// getSmartData handles GET /api/beszel/smart requests
func (h *Hub) getSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
data, err := system.FetchSmartDataFromAgent()
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, data)
}
// generates key pair if it doesn't exist and returns signer

View File

@@ -5,50 +5,39 @@ import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"math/rand"
"net"
"strings"
"sync/atomic"
"time"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/transport"
"github.com/henrygd/beszel/internal/hub/ws"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/henrygd/beszel"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/lxzan/gws"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
sshTransport *transport.SSHTransport // SSH transport for requests
data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
detailsFetched atomic.Bool // True if static system details have been fetched and saved
smartFetching atomic.Bool // True if SMART devices are currently being fetched
smartInterval time.Duration // Interval for periodic SMART data updates
lastSmartFetch atomic.Int64 // Unix milliseconds of last SMART data fetch
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager // Manager that this system belongs to
client *ssh.Client // SSH client for fetching data
data *system.CombinedData // system data from agent
ctx context.Context // Context for stopping the updater
cancel context.CancelFunc // Stops and removes system from updater
WsConn *ws.WsConn // Handler for agent WebSocket connection
agentVersion semver.Version // Agent version
updateTicker *time.Ticker // Ticker for updating the system
}
func (sm *SystemManager) NewSystem(systemId string) *System {
@@ -121,37 +110,10 @@ func (sys *System) update() error {
sys.handlePaused()
return nil
}
options := common.DataRequestOptions{
CacheTimeMs: uint16(interval),
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
if err == nil {
_, err = sys.createRecords(data)
}
// fetch system details if not already fetched
if !sys.detailsFetched.Load() {
options.IncludeDetails = true
}
data, err := sys.fetchDataFromAgent(options)
if err != nil {
return err
}
// create system records
_, err = sys.createRecords(data)
// Fetch and save SMART devices when system first comes online or at intervals
if backgroundSmartFetchEnabled() {
if sys.smartInterval <= 0 {
sys.smartInterval = time.Hour
}
lastFetch := sys.lastSmartFetch.Load()
if time.Since(time.UnixMilli(lastFetch)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
go func() {
defer sys.smartFetching.Store(false)
sys.lastSmartFetch.Store(time.Now().UnixMilli())
_ = sys.FetchAndSaveSmartDevices()
}()
}
}
return err
}
@@ -176,11 +138,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
}
hub := sys.manager.hub
err = hub.RunInTransaction(func(txApp core.App) error {
// add system_stats record
// add system_stats and container_stats records
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return err
}
systemStatsRecord := core.NewRecord(systemStatsCollection)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", data.Stats)
@@ -188,14 +151,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
return err
}
// add containers and container_stats records
if len(data.Containers) > 0 {
// add / update containers records
if data.Containers[0].Id != "" {
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
return err
}
}
// add new container_stats record
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return err
@@ -208,28 +171,9 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return err
}
}
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
return err
}
}
// add system details record
if data.Details != nil {
if err := createSystemDetailsRecord(txApp, data.Details, sys.Id); err != nil {
return err
}
sys.detailsFetched.Store(true)
// update smart interval if it's set on the agent side
if data.Details.SmartInterval > 0 {
sys.smartInterval = data.Details.SmartInterval
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", data.Info)
if err := txApp.SaveNoValidate(systemRecord); err != nil {
return err
@@ -240,68 +184,11 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
return systemRecord, err
}
func createSystemDetailsRecord(app core.App, data *system.Details, systemId string) error {
collectionName := "system_details"
params := dbx.Params{
"id": systemId,
"system": systemId,
"hostname": data.Hostname,
"kernel": data.Kernel,
"cores": data.Cores,
"threads": data.Threads,
"cpu": data.CpuModel,
"os": data.Os,
"os_name": data.OsName,
"arch": data.Arch,
"memory": data.MemoryTotal,
"podman": data.Podman,
"updated": time.Now().UTC(),
}
result, err := app.DB().Update(collectionName, params, dbx.HashExp{"id": systemId}).Execute()
rowsAffected, _ := result.RowsAffected()
if err != nil || rowsAffected == 0 {
_, err = app.DB().Insert(collectionName, params).Execute()
}
return err
}
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, service := range data {
suffix := fmt.Sprintf("%d", i)
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, service.Name)
params["name"+suffix] = service.Name
params["state"+suffix] = service.State
params["sub"+suffix] = service.Sub
params["cpu"+suffix] = service.Cpu
params["cpuPeak"+suffix] = service.CpuPeak
params["memory"+suffix] = service.Mem
params["memPeak"+suffix] = service.MemPeak
}
queryString := fmt.Sprintf(
"INSERT INTO systemd_services (id, system, name, state, sub, cpu, cpuPeak, memory, memPeak, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system = excluded.system, name = excluded.name, state = excluded.state, sub = excluded.sub, cpu = excluded.cpu, cpuPeak = excluded.cpuPeak, memory = excluded.memory, memPeak = excluded.memPeak, updated = excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
// createContainerRecords creates container records
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
@@ -363,78 +250,8 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
return sys.ctx, sys.cancel
}
// request sends a request to the agent, trying WebSocket first, then SSH.
// This is the unified request method that uses the transport abstraction.
func (sys *System) request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
// Try WebSocket first
if sys.WsConn != nil && sys.WsConn.IsConnected() {
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
if err := wsTransport.Request(ctx, action, req, dest); err == nil {
return nil
} else if !shouldFallbackToSSH(err) {
return err
} else if shouldCloseWebSocket(err) {
sys.closeWebSocketConnection()
}
}
// Fall back to SSH if WebSocket fails
if err := sys.ensureSSHTransport(); err != nil {
return err
}
err := sys.sshTransport.RequestWithRetry(ctx, action, req, dest, 1)
// Keep legacy SSH client/version fields in sync for other code paths.
if sys.sshTransport != nil {
sys.client = sys.sshTransport.GetClient()
sys.agentVersion = sys.sshTransport.GetAgentVersion()
}
return err
}
func shouldFallbackToSSH(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return true
}
if errors.Is(err, gws.ErrConnClosed) {
return true
}
return errors.Is(err, transport.ErrWebSocketNotConnected)
}
func shouldCloseWebSocket(err error) bool {
if err == nil {
return false
}
return errors.Is(err, gws.ErrConnClosed) || errors.Is(err, transport.ErrWebSocketNotConnected)
}
// ensureSSHTransport ensures the SSH transport is initialized and connected.
func (sys *System) ensureSSHTransport() error {
if sys.sshTransport == nil {
if sys.manager.sshConfig == nil {
if err := sys.manager.createSSHClientConfig(); err != nil {
return err
}
}
sys.sshTransport = transport.NewSSHTransport(transport.SSHTransportConfig{
Host: sys.Host,
Port: sys.Port,
Config: sys.manager.sshConfig,
Timeout: 4 * time.Second,
})
}
// Sync client state with transport
if sys.client != nil {
sys.sshTransport.SetClient(sys.client)
sys.sshTransport.SetAgentVersion(sys.agentVersion)
}
return nil
}
// fetchDataFromAgent attempts to fetch data from the agent, prioritizing WebSocket if available.
// fetchDataFromAgent attempts to fetch data from the agent,
// prioritizing WebSocket if available.
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
if sys.data == nil {
sys.data = &system.CombinedData{}
@@ -460,56 +277,106 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
return nil, errors.New("no websocket connection")
}
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
if err != nil {
return nil, err
}
return sys.data, nil
}
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
var result string
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: action, Data: requestData}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
err = cbor.NewDecoder(stdout).Decode(&resp)
if err != nil {
return false, err
}
if resp.String == nil {
return false, errors.New(errorMsg)
}
result = *resp.String
return false, nil
})
return result, err
}
// FetchContainerInfoFromAgent fetches container info from the agent
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result)
return result, err
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerInfo(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
// FetchContainerLogsFromAgent fetches container logs from the agent
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result string
err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result)
return result, err
}
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result systemd.ServiceDetails
err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result)
return result, err
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestContainerLogs(ctx, containerID)
}
// fetch via SSH
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// FetchSmartDataFromAgent fetches SMART data from the agent
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var result map[string]smart.SmartData
err := sys.request(ctx, common.GetSmartData, nil, &result)
return result, err
}
func makeStableHashId(strings ...string) string {
hash := fnv.New32a()
for _, str := range strings {
hash.Write([]byte(str))
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
// fetch via websocket
if sys.WsConn != nil && sys.WsConn.IsConnected() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return sys.WsConn.RequestSmartData(ctx)
}
return fmt.Sprintf("%x", hash.Sum32())
// fetch via SSH
var result map[string]any
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
stdout, err := session.StdoutPipe()
if err != nil {
return false, err
}
stdin, stdinErr := session.StdinPipe()
if stdinErr != nil {
return false, stdinErr
}
if err := session.Shell(); err != nil {
return false, err
}
req := common.HubRequest[any]{Action: common.GetSmartData}
_ = cbor.NewEncoder(stdin).Encode(req)
_ = stdin.Close()
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return false, err
}
// Convert to generic map for JSON response
result = make(map[string]any, len(resp.SmartData))
for k, v := range resp.SmartData {
result[k] = v
}
return false, nil
})
return result, err
}
// fetchDataViaSSH handles fetching data using SSH.
@@ -665,9 +532,6 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
// closeSSHConnection closes the SSH connection but keeps the system in the manager
func (sys *System) closeSSHConnection() {
if sys.sshTransport != nil {
sys.sshTransport.Close()
}
if sys.client != nil {
sys.client.Close()
sys.client = nil

View File

@@ -1,92 +0,0 @@
package systems
import (
"database/sql"
"errors"
"strings"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/pocketbase/pocketbase/core"
)
// 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
}

View File

@@ -1,75 +0,0 @@
//go:build testing
package systems
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSystemdServiceId(t *testing.T) {
t.Run("deterministic output", func(t *testing.T) {
systemId := "sys-123"
serviceName := "nginx.service"
// Call multiple times and ensure same result
id1 := makeStableHashId(systemId, serviceName)
id2 := makeStableHashId(systemId, serviceName)
id3 := makeStableHashId(systemId, serviceName)
assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3)
assert.NotEmpty(t, id1)
})
t.Run("different inputs produce different ids", func(t *testing.T) {
systemId1 := "sys-123"
systemId2 := "sys-456"
serviceName1 := "nginx.service"
serviceName2 := "apache.service"
id1 := makeStableHashId(systemId1, serviceName1)
id2 := makeStableHashId(systemId2, serviceName1)
id3 := makeStableHashId(systemId1, serviceName2)
id4 := makeStableHashId(systemId2, serviceName2)
// All IDs should be different
assert.NotEqual(t, id1, id2)
assert.NotEqual(t, id1, id3)
assert.NotEqual(t, id1, id4)
assert.NotEqual(t, id2, id3)
assert.NotEqual(t, id2, id4)
assert.NotEqual(t, id3, id4)
})
t.Run("consistent length", func(t *testing.T) {
testCases := []struct {
systemId string
serviceName string
}{
{"short", "short.service"},
{"very-long-system-id-that-might-be-used-in-practice", "very-long-service-name.service"},
{"", "empty-system.service"},
{"empty-service", ""},
{"", ""},
}
for _, tc := range testCases {
id := makeStableHashId(tc.systemId, tc.serviceName)
// FNV-32 produces 8 hex characters
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
}
})
t.Run("hexadecimal output", func(t *testing.T) {
id := makeStableHashId("test-system", "test-service")
assert.NotEmpty(t, id)
// Should only contain hexadecimal characters
for _, char := range id {
assert.True(t, (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f'),
"ID should only contain hexadecimal characters, got: %s", id)
}
})
}

View File

@@ -1,10 +0,0 @@
//go:build !testing
// +build !testing
package systems
// Background SMART fetching is enabled in production but disabled for tests (systems_test_helpers.go).
//
// The hub integration tests create/replace systems and clean up the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
func backgroundSmartFetchEnabled() bool { return true }

View File

@@ -266,20 +266,18 @@ func testOld(t *testing.T, hub *tests.TestHub) {
// Create test system data
testData := &system.CombinedData{
Details: &system.Details{
Hostname: "data-test.example.com",
Kernel: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
},
Info: system.Info{
Uptime: 3600,
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
Hostname: "data-test.example.com",
KernelVersion: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
Uptime: 3600,
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
},
Stats: system.Stats{
Cpu: 25.5,

View File

@@ -10,13 +10,6 @@ import (
entities "github.com/henrygd/beszel/internal/entities/system"
)
// The hub integration tests create/replace systems and cleanup the test apps quickly.
// Background SMART fetching can outlive teardown and crash in PocketBase internals (nil DB).
//
// We keep the explicit SMART refresh endpoint / method available, but disable
// the automatic background fetch during tests.
func backgroundSmartFetchEnabled() bool { return false }
// TESTING ONLY: GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()

View File

@@ -1,227 +0,0 @@
package transport
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/blang/semver"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"golang.org/x/crypto/ssh"
)
// SSHTransport implements Transport over SSH connections.
type SSHTransport struct {
client *ssh.Client
config *ssh.ClientConfig
host string
port string
agentVersion semver.Version
timeout time.Duration
}
// SSHTransportConfig holds configuration for creating an SSH transport.
type SSHTransportConfig struct {
Host string
Port string
Config *ssh.ClientConfig
AgentVersion semver.Version
Timeout time.Duration
}
// NewSSHTransport creates a new SSH transport with the given configuration.
func NewSSHTransport(cfg SSHTransportConfig) *SSHTransport {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 4 * time.Second
}
return &SSHTransport{
config: cfg.Config,
host: cfg.Host,
port: cfg.Port,
agentVersion: cfg.AgentVersion,
timeout: timeout,
}
}
// SetClient sets the SSH client for reuse across requests.
func (t *SSHTransport) SetClient(client *ssh.Client) {
t.client = client
}
// SetAgentVersion sets the agent version (extracted from SSH handshake).
func (t *SSHTransport) SetAgentVersion(version semver.Version) {
t.agentVersion = version
}
// GetClient returns the current SSH client (for connection management).
func (t *SSHTransport) GetClient() *ssh.Client {
return t.client
}
// GetAgentVersion returns the agent version.
func (t *SSHTransport) GetAgentVersion() semver.Version {
return t.agentVersion
}
// Request sends a request to the agent via SSH and unmarshals the response.
func (t *SSHTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
if t.client == nil {
if err := t.connect(); err != nil {
return err
}
}
session, err := t.createSessionWithTimeout(ctx)
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
// Send request
hubReq := common.HubRequest[any]{Action: action, Data: req}
if err := cbor.NewEncoder(stdin).Encode(hubReq); err != nil {
return fmt.Errorf("failed to encode request: %w", err)
}
stdin.Close()
// Read response
var resp common.AgentResponse
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if resp.Error != "" {
return errors.New(resp.Error)
}
if err := session.Wait(); err != nil {
return err
}
return UnmarshalResponse(resp, action, dest)
}
// IsConnected returns true if the SSH connection is active.
func (t *SSHTransport) IsConnected() bool {
return t.client != nil
}
// Close terminates the SSH connection.
func (t *SSHTransport) Close() {
if t.client != nil {
t.client.Close()
t.client = nil
}
}
// connect establishes a new SSH connection.
func (t *SSHTransport) connect() error {
if t.config == nil {
return errors.New("SSH config not set")
}
network := "tcp"
host := t.host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, t.port)
}
client, err := ssh.Dial(network, host, t.config)
if err != nil {
return err
}
t.client = client
// Extract agent version from server version string
t.agentVersion, _ = extractAgentVersion(string(client.Conn.ServerVersion()))
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout.
func (t *SSHTransport) createSessionWithTimeout(ctx context.Context) (*ssh.Session, error) {
if t.client == nil {
return nil, errors.New("client not initialized")
}
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
session, err := t.client.NewSession()
if err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, errors.New("timeout creating session")
}
}
// extractAgentVersion extracts the beszel version from SSH server version string.
func extractAgentVersion(versionString string) (semver.Version, error) {
_, after, _ := strings.Cut(versionString, "_")
return semver.Parse(after)
}
// RequestWithRetry sends a request with automatic retry on connection failures.
func (t *SSHTransport) RequestWithRetry(ctx context.Context, action common.WebSocketAction, req any, dest any, retries int) error {
var lastErr error
for attempt := 0; attempt <= retries; attempt++ {
err := t.Request(ctx, action, req, dest)
if err == nil {
return nil
}
lastErr = err
// Check if it's a connection error that warrants a retry
if isConnectionError(err) && attempt < retries {
t.Close()
continue
}
return err
}
return lastErr
}
// isConnectionError checks if an error indicates a connection problem.
func isConnectionError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "connection") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "closed") ||
errors.Is(err, io.EOF)
}

View File

@@ -1,112 +0,0 @@
// Package transport provides a unified abstraction for hub-agent communication
// over different transports (WebSocket, SSH).
package transport
import (
"context"
"errors"
"fmt"
"github.com/fxamacker/cbor/v2"
"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/systemd"
)
// Transport defines the interface for hub-agent communication.
// Both WebSocket and SSH transports implement this interface.
type Transport interface {
// Request sends a request to the agent and unmarshals the response into dest.
// The dest parameter should be a pointer to the expected response type.
Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error
// IsConnected returns true if the transport connection is active.
IsConnected() bool
// Close terminates the transport connection.
Close()
}
// UnmarshalResponse unmarshals an AgentResponse into the destination type.
// It first checks the generic Data field (0.19+ agents), then falls back
// to legacy typed fields for backward compatibility with 0.18.0 agents.
func UnmarshalResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
if dest == nil {
return errors.New("nil destination")
}
// Try generic Data field first (0.19+)
if len(resp.Data) > 0 {
if err := cbor.Unmarshal(resp.Data, dest); err != nil {
return fmt.Errorf("failed to unmarshal generic response data: %w", err)
}
return nil
}
// Fall back to legacy typed fields for older agents/hubs.
return unmarshalLegacyResponse(resp, action, dest)
}
// unmarshalLegacyResponse handles legacy responses that use typed fields.
func unmarshalLegacyResponse(resp common.AgentResponse, action common.WebSocketAction, dest any) error {
switch action {
case common.GetData:
d, ok := dest.(*system.CombinedData)
if !ok {
return fmt.Errorf("unexpected dest type for GetData: %T", dest)
}
if resp.SystemData == nil {
return errors.New("no system data in response")
}
*d = *resp.SystemData
return nil
case common.CheckFingerprint:
d, ok := dest.(*common.FingerprintResponse)
if !ok {
return fmt.Errorf("unexpected dest type for CheckFingerprint: %T", dest)
}
if resp.Fingerprint == nil {
return errors.New("no fingerprint in response")
}
*d = *resp.Fingerprint
return nil
case common.GetContainerLogs:
d, ok := dest.(*string)
if !ok {
return fmt.Errorf("unexpected dest type for GetContainerLogs: %T", dest)
}
if resp.String == nil {
return errors.New("no logs in response")
}
*d = *resp.String
return nil
case common.GetContainerInfo:
d, ok := dest.(*string)
if !ok {
return fmt.Errorf("unexpected dest type for GetContainerInfo: %T", dest)
}
if resp.String == nil {
return errors.New("no info in response")
}
*d = *resp.String
return nil
case common.GetSmartData:
d, ok := dest.(*map[string]smart.SmartData)
if !ok {
return fmt.Errorf("unexpected dest type for GetSmartData: %T", dest)
}
if resp.SmartData == nil {
return errors.New("no SMART data in response")
}
*d = resp.SmartData
return nil
case common.GetSystemdInfo:
d, ok := dest.(*systemd.ServiceDetails)
if !ok {
return fmt.Errorf("unexpected dest type for GetSystemdInfo: %T", dest)
}
if resp.ServiceInfo == nil {
return errors.New("no systemd info in response")
}
*d = resp.ServiceInfo
return nil
}
return fmt.Errorf("unsupported action: %d", action)
}

View File

@@ -1,74 +0,0 @@
package transport
import (
"context"
"errors"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/hub/ws"
)
// ErrWebSocketNotConnected indicates a WebSocket transport is not currently connected.
var ErrWebSocketNotConnected = errors.New("websocket not connected")
// WebSocketTransport implements Transport over WebSocket connections.
type WebSocketTransport struct {
wsConn *ws.WsConn
}
// NewWebSocketTransport creates a new WebSocket transport wrapper.
func NewWebSocketTransport(wsConn *ws.WsConn) *WebSocketTransport {
return &WebSocketTransport{wsConn: wsConn}
}
// Request sends a request to the agent via WebSocket and unmarshals the response.
func (t *WebSocketTransport) Request(ctx context.Context, action common.WebSocketAction, req any, dest any) error {
if !t.IsConnected() {
return ErrWebSocketNotConnected
}
pendingReq, err := t.wsConn.SendRequest(ctx, action, req)
if err != nil {
return err
}
// Wait for response
select {
case message := <-pendingReq.ResponseCh:
defer message.Close()
defer pendingReq.Cancel()
// Legacy agents (< MinVersionAgentResponse) respond with a raw payload instead of an AgentResponse wrapper.
if t.wsConn.AgentVersion().LT(beszel.MinVersionAgentResponse) {
return cbor.Unmarshal(message.Data.Bytes(), dest)
}
var agentResponse common.AgentResponse
if err := cbor.Unmarshal(message.Data.Bytes(), &agentResponse); err != nil {
return err
}
if agentResponse.Error != "" {
return errors.New(agentResponse.Error)
}
return UnmarshalResponse(agentResponse, action, dest)
case <-pendingReq.Context.Done():
return pendingReq.Context.Err()
}
}
// IsConnected returns true if the WebSocket connection is active.
func (t *WebSocketTransport) IsConnected() bool {
return t.wsConn != nil && t.wsConn.IsConnected()
}
// Close terminates the WebSocket connection.
func (t *WebSocketTransport) Close() {
if t.wsConn != nil {
t.wsConn.Close(nil)
}
}

View File

@@ -6,12 +6,12 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/lxzan/gws"
"golang.org/x/crypto/ssh"
)
// ResponseHandler defines interface for handling agent responses.
// This is used by handleAgentRequest for legacy response handling.
// ResponseHandler defines interface for handling agent responses
type ResponseHandler interface {
Handle(agentResponse common.AgentResponse) error
HandleLegacy(rawData []byte) error
@@ -25,7 +25,134 @@ func (h *BaseHandler) HandleLegacy(rawData []byte) error {
}
////////////////////////////////////////////////////////////////////////////
// Fingerprint handling (used for WebSocket authentication)
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// systemDataHandler implements ResponseHandler for system data requests
type systemDataHandler struct {
data *system.CombinedData
}
func (h *systemDataHandler) HandleLegacy(rawData []byte) error {
return cbor.Unmarshal(rawData, h.data)
}
func (h *systemDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SystemData != nil {
*h.data = *agentResponse.SystemData
}
return nil
}
// RequestSystemData requests system metrics from the agent and unmarshals the response.
func (ws *WsConn) RequestSystemData(ctx context.Context, data *system.CombinedData, options common.DataRequestOptions) error {
if !ws.IsConnected() {
return gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetData, options)
if err != nil {
return err
}
handler := &systemDataHandler{data: data}
return ws.handleAgentRequest(req, handler)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// stringResponseHandler is a generic handler for string responses from agents
type stringResponseHandler struct {
BaseHandler
value string
errorMsg string
}
func (h *stringResponseHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.String == nil {
return errors.New(h.errorMsg)
}
h.value = *agentResponse.String
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// requestContainerStringViaWS is a generic function to request container-related strings via WebSocket
func (ws *WsConn) requestContainerStringViaWS(ctx context.Context, action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
if !ws.IsConnected() {
return "", gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, action, requestData)
if err != nil {
return "", err
}
handler := &stringResponseHandler{errorMsg: errorMsg}
if err := ws.handleAgentRequest(req, handler); err != nil {
return "", err
}
return handler.value, nil
}
// RequestContainerLogs requests logs for a specific container via WebSocket.
func (ws *WsConn) RequestContainerLogs(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
}
// RequestContainerInfo requests information about a specific container via WebSocket.
func (ws *WsConn) RequestContainerInfo(ctx context.Context, containerID string) (string, error) {
return ws.requestContainerStringViaWS(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// RequestSmartData requests SMART data via WebSocket.
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]any, error) {
if !ws.IsConnected() {
return nil, gws.ErrConnClosed
}
req, err := ws.requestManager.SendRequest(ctx, common.GetSmartData, nil)
if err != nil {
return nil, err
}
var result map[string]any
handler := ResponseHandler(&smartDataHandler{result: &result})
if err := ws.handleAgentRequest(req, handler); err != nil {
return nil, err
}
return result, nil
}
// smartDataHandler parses SMART data map from AgentResponse
type smartDataHandler struct {
BaseHandler
result *map[string]any
}
func (h *smartDataHandler) Handle(agentResponse common.AgentResponse) error {
if agentResponse.SmartData == nil {
return errors.New("no SMART data in response")
}
// convert to map[string]any for transport convenience in hub layer
out := make(map[string]any, len(agentResponse.SmartData))
for k, v := range agentResponse.SmartData {
out[k] = v
}
*h.result = out
return nil
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// fingerprintHandler implements ResponseHandler for fingerprint requests

View File

@@ -45,15 +45,7 @@ func NewRequestManager(conn *gws.Conn) *RequestManager {
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
reqID := RequestID(rm.nextID.Add(1))
// Respect any caller-provided deadline. If none is set, apply a reasonable default
// so pending requests don't live forever if the agent never responds.
reqCtx := ctx
var cancel context.CancelFunc
if _, hasDeadline := ctx.Deadline(); hasDeadline {
reqCtx, cancel = context.WithCancel(ctx)
} else {
reqCtx, cancel = context.WithTimeout(ctx, 5*time.Second)
}
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
req := &PendingRequest{
ID: reqID,
@@ -108,11 +100,6 @@ func (rm *RequestManager) handleResponse(message *gws.Message) {
return
}
if response.Id == nil {
rm.routeLegacyResponse(message)
return
}
reqID := RequestID(*response.Id)
rm.RLock()

View File

@@ -1,7 +1,6 @@
package ws
import (
"context"
"errors"
"time"
"weak"
@@ -162,14 +161,3 @@ func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandle
func (ws *WsConn) IsConnected() bool {
return ws.conn != nil
}
// AgentVersion returns the connected agent's version (as reported during handshake).
func (ws *WsConn) AgentVersion() semver.Version {
return ws.agentVersion
}
// SendRequest sends a request to the agent and returns a pending request handle.
// This is used by the transport layer to send requests.
func (ws *WsConn) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
return ws.requestManager.SendRequest(ctx, action, data)
}

View File

@@ -184,18 +184,14 @@ func TestCommonActions(t *testing.T) {
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
}
func TestFingerprintHandler(t *testing.T) {
var result common.FingerprintResponse
h := &fingerprintHandler{result: &result}
func TestLogsHandler(t *testing.T) {
h := &stringResponseHandler{errorMsg: "no logs in response"}
resp := common.AgentResponse{Fingerprint: &common.FingerprintResponse{
Fingerprint: "test-fingerprint",
Hostname: "test-host",
}}
logValue := "test logs"
resp := common.AgentResponse{String: &logValue}
err := h.Handle(resp)
assert.NoError(t, err)
assert.Equal(t, "test-fingerprint", result.Fingerprint)
assert.Equal(t, "test-host", result.Hostname)
assert.Equal(t, logValue, h.value)
}
// TestHandler tests that we can create a Handler

View File

@@ -75,11 +75,9 @@ func init() {
"Disk",
"Temperature",
"Bandwidth",
"GPU",
"LoadAvg1",
"LoadAvg5",
"LoadAvg15",
"Battery"
"LoadAvg15"
]
},
{
@@ -1009,614 +1007,6 @@ func init() {
"CREATE INDEX ` + "`" + `idx_r3Ja0rs102` + "`" + ` ON ` + "`" + `containers` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
},
{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 6,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "number2063623452",
"max": null,
"min": null,
"name": "state",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1476559580",
"max": null,
"min": null,
"name": "sub",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3128971310",
"max": null,
"min": null,
"name": "cpu",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1052053287",
"max": null,
"min": null,
"name": "cpuPeak",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": null,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1828797201",
"max": null,
"min": null,
"name": "memPeak",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3332085495",
"max": null,
"min": null,
"name": "updated",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
}
],
"id": "pbc_3494996990",
"indexes": [
"CREATE INDEX ` + "`" + `idx_4Z7LuLNdQb` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `system` + "`" + `)",
"CREATE INDEX ` + "`" + `idx_pBp1fF837e` + "`" + ` ON ` + "`" + `systemd_services` + "`" + ` (` + "`" + `updated` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "systemd_services",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 10,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "relation2375276105",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "select2844932856",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"one-time",
"daily"
]
},
{
"hidden": false,
"id": "date2675529103",
"max": "",
"min": "",
"name": "start",
"presentable": false,
"required": true,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "date16528305",
"max": "",
"min": "",
"name": "end",
"presentable": false,
"required": true,
"system": false,
"type": "date"
}
],
"id": "pbc_451525641",
"indexes": [
"CREATE INDEX ` + "`" + `idx_q0iKnRP9v8` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `\n)",
"CREATE INDEX ` + "`" + `idx_6T7ljT7FJd` + "`" + ` ON ` + "`" + `quiet_hours` + "`" + ` (\n ` + "`" + `type` + "`" + `,\n ` + "`" + `end` + "`" + `\n)"
],
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"name": "quiet_hours",
"system": false,
"type": "base",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "@request.auth.id != \"\" && user.id = @request.auth.id"
},
{
"createRule": null,
"deleteRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{10}",
"hidden": false,
"id": "text3208210256",
"max": 10,
"min": 10,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3616895705",
"max": 0,
"min": 0,
"name": "model",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2744374011",
"max": 0,
"min": 0,
"name": "state",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number3051925876",
"max": null,
"min": null,
"name": "capacity",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number190023114",
"max": null,
"min": null,
"name": "temp",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3589068740",
"max": 0,
"min": 0,
"name": "firmware",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3547646428",
"max": 0,
"min": 0,
"name": "serial",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2363381545",
"max": 0,
"min": 0,
"name": "type",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1234567890",
"max": null,
"min": null,
"name": "hours",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number0987654321",
"max": null,
"min": null,
"name": "cycles",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "json832282224",
"maxSize": 0,
"name": "attributes",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2571630677",
"indexes": [
"CREATE INDEX ` + "`" + `idx_DZ9yhvgl44` + "`" + ` ON ` + "`" + `smart_devices` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "smart_devices",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
},
{
"createRule": "",
"deleteRule": "",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "relation3377271179",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3847340049",
"max": 0,
"min": 0,
"name": "hostname",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number1789936913",
"max": null,
"min": null,
"name": "os",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2818598173",
"max": 0,
"min": 0,
"name": "os_name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1574083243",
"max": 0,
"min": 0,
"name": "kernel",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3128971310",
"max": 0,
"min": 0,
"name": "cpu",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4161937994",
"max": 0,
"min": 0,
"name": "arch",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "number4245036687",
"max": null,
"min": null,
"name": "cores",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1871592925",
"max": null,
"min": null,
"name": "threads",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3933025333",
"max": null,
"min": null,
"name": "memory",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool2200265312",
"name": "podman",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_3116237454",
"indexes": [],
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
"name": "system_details",
"system": false,
"type": "base",
"updateRule": "",
"viewRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id"
}
]`

View File

@@ -0,0 +1,50 @@
package migrations
import (
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
// This can be deleted after Nov 2025 or so
func init() {
m.Register(func(app core.App) error {
app.RunInTransaction(func(txApp core.App) error {
var systemIds []string
txApp.DB().NewQuery("SELECT id FROM systems").Column(&systemIds)
for _, systemId := range systemIds {
var statRecordIds []string
txApp.DB().NewQuery("SELECT id FROM system_stats WHERE system = {:system} AND created > {:created}").Bind(map[string]any{"system": systemId, "created": "2025-09-21"}).Column(&statRecordIds)
for _, statRecordId := range statRecordIds {
statRecord, err := txApp.FindRecordById("system_stats", statRecordId)
if err != nil {
return err
}
var systemStats system.Stats
err = statRecord.UnmarshalJSONField("stats", &systemStats)
if err != nil {
return err
}
// if mem buff cache is less than total mem, we don't need to fix it
if systemStats.MemBuffCache < systemStats.Mem {
continue
}
systemStats.MemBuffCache = 0
statRecord.Set("stats", systemStats)
err = txApp.SaveNoValidate(statRecord)
if err != nil {
return err
}
}
}
return nil
})
return nil
}, func(app core.App) error {
return nil
})
}

View File

@@ -490,18 +490,10 @@ func (rm *RecordManager) DeleteOldRecords() {
if err != nil {
return err
}
err = deleteOldSystemdServiceRecords(txApp)
if err != nil {
return err
}
err = deleteOldAlertsHistory(txApp, 200, 250)
if err != nil {
return err
}
err = deleteOldQuietHours(txApp)
if err != nil {
return err
}
return nil
})
}
@@ -567,20 +559,6 @@ func deleteOldSystemStats(app core.App) error {
return nil
}
// Deletes systemd service records that haven't been updated in the last 20 minutes
func deleteOldSystemdServiceRecords(app core.App) error {
now := time.Now().UTC()
twentyMinutesAgo := now.Add(-20 * time.Minute)
// Delete systemd service records where updated < twentyMinutesAgo
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
if err != nil {
return fmt.Errorf("failed to delete old systemd service records: %v", err)
}
return nil
}
// Deletes container records that haven't been updated in the last 10 minutes
func deleteOldContainerRecords(app core.App) error {
now := time.Now().UTC()
@@ -595,17 +573,6 @@ func deleteOldContainerRecords(app core.App) error {
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 */
func twoDecimals(value float64) float64 {
return math.Round(value*100) / 100

View File

@@ -351,83 +351,6 @@ func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
})
}
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
rm := records.NewRecordManager(hub)
// Create test user and system
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
require.NoError(t, err)
system, err := tests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"host": "localhost",
"port": "45876",
"status": "up",
"users": []string{user.Id},
})
require.NoError(t, err)
now := time.Now().UTC()
// Create old systemd service records that should be deleted (older than 20 minutes)
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0, // Active
"sub": 1, // Running
"cpu": 5.0,
"cpuPeak": 10.0,
"memory": 1024000,
"memPeak": 2048000,
})
require.NoError(t, err)
// Set updated time to 25 minutes ago (should be deleted)
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
err = hub.SaveNoValidate(oldRecord)
require.NoError(t, err)
// Create recent systemd service record that should be kept (within 20 minutes)
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
"system": system.Id,
"name": "apache.service",
"state": 1, // Inactive
"sub": 0, // Dead
"cpu": 2.0,
"cpuPeak": 3.0,
"memory": 512000,
"memPeak": 1024000,
})
require.NoError(t, err)
// Set updated time to 10 minutes ago (should be kept)
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
err = hub.SaveNoValidate(recentRecord)
require.NoError(t, err)
// Count records before deletion
countBefore, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
// Run deletion via RecordManager
rm.DeleteOldRecords()
// Count records after deletion
countAfter, err := hub.CountRecords("systemd_services")
require.NoError(t, err)
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
// Verify the correct record was kept
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
require.NoError(t, err)
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
}
// TestRecordManagerCreation tests RecordManager creation
func TestRecordManagerCreation(t *testing.T) {
hub, err := tests.NewTestHub(t.TempDir())

View File

@@ -17,9 +17,6 @@
"enabled": true,
"rules": {
"recommended": true,
"a11y": {
"useButtonType": "off"
},
"complexity": {
"noUselessStringConcat": "error",
"noUselessUndefinedInitialization": "error",
@@ -33,14 +30,13 @@
"noUnusedFunctionParameters": "error",
"noUnusedPrivateClassMembers": "error",
"useExhaustiveDependencies": {
"level": "off"
"level": "error",
"options": {
"reportUnnecessaryDependencies": false
}
},
"useUniqueElementIds": "off",
"noUnusedVariables": "error"
},
"security": {
"noDangerouslySetInnerHtml": "warn"
},
"style": {
"noParameterProperties": "error",
"noYodaExpression": "error",
@@ -51,8 +47,7 @@
},
"suspicious": {
"useAwait": "error",
"noEvolvingTypes": "error",
"noArrayIndexKey": "off"
"noEvolvingTypes": "error"
}
}
},

View File

@@ -1,996 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "beszel",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
"@lingui/detect-locale": "^5.4.1",
"@lingui/macro": "^5.4.1",
"@lingui/react": "^5.4.1",
"@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-direction": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-time": "^3.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.26.2",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^0.42.1",
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@lingui/cli": "^5.4.1",
"@lingui/swc-plugin": "^5.6.1",
"@lingui/vite-plugin": "^5.4.1",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.12",
"@types/bun": "^1.2.20",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react-swc": "^4.0.1",
"tailwindcss": "^4.1.12",
"tw-animate-css": "^1.3.7",
"typescript": "^5.9.2",
"vite": "^7.1.3",
},
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5",
},
},
},
"packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
"@babel/core": ["@babel/core@7.28.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.3", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ=="],
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="],
"@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
"@babel/runtime": ["@babel/runtime@7.26.10", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/traverse": ["@babel/traverse@7.28.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
"@biomejs/biome": ["@biomejs/biome@2.2.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.4", "@biomejs/cli-darwin-x64": "2.2.4", "@biomejs/cli-linux-arm64": "2.2.4", "@biomejs/cli-linux-arm64-musl": "2.2.4", "@biomejs/cli-linux-x64": "2.2.4", "@biomejs/cli-linux-x64-musl": "2.2.4", "@biomejs/cli-win32-arm64": "2.2.4", "@biomejs/cli-win32-x64": "2.2.4" }, "bin": { "biome": "bin/biome" } }, "sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg=="],
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA=="],
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg=="],
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw=="],
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ=="],
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ=="],
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg=="],
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ=="],
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.6", "", { "os": "aix", "cpu": "ppc64" }, "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.6", "", { "os": "android", "cpu": "arm" }, "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.6", "", { "os": "android", "cpu": "arm64" }, "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.6", "", { "os": "android", "cpu": "x64" }, "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.6", "", { "os": "linux", "cpu": "arm" }, "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.6", "", { "os": "linux", "cpu": "ia32" }, "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.6", "", { "os": "none", "cpu": "x64" }, "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.6", "", { "os": "sunos", "cpu": "x64" }, "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@henrygd/queue": ["@henrygd/queue@1.0.7", "", {}, "sha512-Jmt/iO6yDlz9UYGILkm/Qzi/ckkEiTNZcqDvt3QFLE4OThPeiCj6tKsynHFm/ppl8RumWXAx1dZPBPiRPaaGig=="],
"@henrygd/semaphore": ["@henrygd/semaphore@0.0.2", "", {}, "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
"@jest/types": ["@jest/types@29.6.3", "", { "dependencies": { "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", "@types/yargs": "^17.0.8", "chalk": "^4.0.0" } }, "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
"@lingui/babel-plugin-extract-messages": ["@lingui/babel-plugin-extract-messages@5.4.1", "", {}, "sha512-sjkVaLyuK3ZW62mv5gU6pOdl3ZpwDReeSaNodJuf9LssbMIQPa5WOirTnMeBaalrQ8BA2srrRzQAWgsXPQVdXA=="],
"@lingui/babel-plugin-lingui-macro": ["@lingui/babel-plugin-lingui-macro@5.4.1", "", { "dependencies": { "@babel/core": "^7.20.12", "@babel/runtime": "^7.20.13", "@babel/types": "^7.20.7", "@lingui/conf": "5.4.1", "@lingui/core": "5.4.1", "@lingui/message-utils": "5.4.1" }, "peerDependencies": { "babel-plugin-macros": "2 || 3" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw=="],
"@lingui/cli": ["@lingui/cli@5.4.1", "", { "dependencies": { "@babel/core": "^7.21.0", "@babel/generator": "^7.21.1", "@babel/parser": "^7.22.0", "@babel/runtime": "^7.21.0", "@babel/types": "^7.21.2", "@lingui/babel-plugin-extract-messages": "5.4.1", "@lingui/babel-plugin-lingui-macro": "5.4.1", "@lingui/conf": "5.4.1", "@lingui/core": "5.4.1", "@lingui/format-po": "5.4.1", "@lingui/message-utils": "5.4.1", "chokidar": "3.5.1", "cli-table": "^0.3.11", "commander": "^10.0.0", "convert-source-map": "^2.0.0", "date-fns": "^3.6.0", "esbuild": "^0.25.1", "glob": "^11.0.0", "micromatch": "^4.0.7", "normalize-path": "^3.0.0", "ora": "^5.1.0", "picocolors": "^1.1.1", "pofile": "^1.1.4", "pseudolocale": "^2.0.0", "source-map": "^0.8.0-beta.0" }, "bin": { "lingui": "dist/lingui.js" } }, "sha512-UAKA9Iz4zMDJS7fzWMZ4hzQWontrTBnI5XCsPm7ttB0Ed0F4Pwph/Vu7pg4bJdiYr4d6nqEpRWd9aTxcC15/IA=="],
"@lingui/conf": ["@lingui/conf@5.4.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "cosmiconfig": "^8.0.0", "jest-validate": "^29.4.3", "jiti": "^1.17.1", "picocolors": "^1.1.1" } }, "sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg=="],
"@lingui/core": ["@lingui/core@5.4.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@lingui/message-utils": "5.4.1" }, "peerDependencies": { "@lingui/babel-plugin-lingui-macro": "5.4.1", "babel-plugin-macros": "2 || 3" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw=="],
"@lingui/detect-locale": ["@lingui/detect-locale@5.4.1", "", {}, "sha512-X6qM6Uw6EhcQj3z5sOIR/wVmhhJfa3SCvMLOzFxCWvm90yocMvuFrCZnmlhvzcGy6xLYO3PA/smHnAmWKUlU3g=="],
"@lingui/format-po": ["@lingui/format-po@5.4.1", "", { "dependencies": { "@lingui/conf": "5.4.1", "@lingui/message-utils": "5.4.1", "date-fns": "^3.6.0", "pofile": "^1.1.4" } }, "sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA=="],
"@lingui/macro": ["@lingui/macro@5.4.1", "", { "dependencies": { "@lingui/core": "5.4.1", "@lingui/react": "5.4.1" }, "peerDependencies": { "@lingui/babel-plugin-lingui-macro": "5.4.1", "babel-plugin-macros": "2 || 3" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-tBpcZCmyppe1OjMQyka+SvexG+iIWSlecmeMoZhf4bRWEDlGTfIuAoosZhVqsiyaaaBNJqpopOxJXf/Tgm7oqQ=="],
"@lingui/message-utils": ["@lingui/message-utils@5.4.1", "", { "dependencies": { "@messageformat/parser": "^5.0.0", "js-sha256": "^0.10.1" } }, "sha512-hXfL90fFBoKp5YgLaWo3HbJS/7q+WlWs7VwVbUxl4pa+YladqNZf08JoDeBUDtlEVx5a3bNUSACXHs2FZo12aw=="],
"@lingui/react": ["@lingui/react@5.4.1", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@lingui/core": "5.4.1" }, "peerDependencies": { "@lingui/babel-plugin-lingui-macro": "5.4.1", "babel-plugin-macros": "2 || 3", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-BfWHRTyu+Je4Km9ZYCTgFuRHgQI9TJa/fMYKJBw3dNy3I09oIqnJ21qbdDimnb/Z9ztMCGQ75EIFoqAB6bdwaw=="],
"@lingui/swc-plugin": ["@lingui/swc-plugin@5.6.1", "", { "peerDependencies": { "@lingui/core": "5" } }, "sha512-kT/ghCKMlTa+SJZU/xn2vvU1QE3/NO3m3Feg6r2OVOovAB6VHKShVElU5truBC2KXn/cPqE9Kz2Yj0+jUmO6xQ=="],
"@lingui/vite-plugin": ["@lingui/vite-plugin@5.4.1", "", { "dependencies": { "@lingui/cli": "5.4.1", "@lingui/conf": "5.4.1" }, "peerDependencies": { "vite": "^3 || ^4 || ^5.0.9 || ^6 || ^7" } }, "sha512-4BxkHliJdGk7lmD++Bee9iy+n66kUONUPgpNqEgcuoEfaL0UgWWLbpkOr42X3tMUVt/S/SUM7firx6NexSCJ4Q=="],
"@messageformat/parser": ["@messageformat/parser@5.1.1", "", { "dependencies": { "moo": "^0.5.1" } }, "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg=="],
"@nanostores/react": ["@nanostores/react@0.7.3", "", { "peerDependencies": { "nanostores": "^0.9.0 || ^0.10.0 || ^0.11.0", "react": ">=18.0.0" } }, "sha512-/XuLAMENRu/Q71biW4AZ4qmU070vkZgiQ28gaTSNRPm2SZF5zGAR81zPE1MaMB4SeOp6ZTst92NBaG75XSspNg=="],
"@nanostores/router": ["@nanostores/router@0.11.0", "", { "peerDependencies": { "nanostores": "^0.9.0" } }, "sha512-QlcneDqXVIsQL3agOS59d9gJQ/9M3qyVOWVttARL5Xvpmovtq91oOYcQxKbLq9i7iitGs5yDJmabe/O3QjWddQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.32", "", {}, "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.48.1", "", { "os": "android", "cpu": "arm" }, "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.48.1", "", { "os": "android", "cpu": "arm64" }, "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.48.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.48.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.48.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.48.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.48.1", "", { "os": "linux", "cpu": "arm" }, "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.48.1", "", { "os": "linux", "cpu": "arm" }, "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.48.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.48.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ=="],
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.48.1", "", { "os": "linux", "cpu": "none" }, "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.48.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.48.1", "", { "os": "linux", "cpu": "none" }, "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.48.1", "", { "os": "linux", "cpu": "none" }, "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.48.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.48.1", "", { "os": "linux", "cpu": "x64" }, "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.48.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.48.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.48.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.48.1", "", { "os": "win32", "cpu": "x64" }, "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg=="],
"@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
"@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
"@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
"@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
"@swc/core": ["@swc/core@1.13.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.13.5", "@swc/core-darwin-x64": "1.13.5", "@swc/core-linux-arm-gnueabihf": "1.13.5", "@swc/core-linux-arm64-gnu": "1.13.5", "@swc/core-linux-arm64-musl": "1.13.5", "@swc/core-linux-x64-gnu": "1.13.5", "@swc/core-linux-x64-musl": "1.13.5", "@swc/core-win32-arm64-msvc": "1.13.5", "@swc/core-win32-ia32-msvc": "1.13.5", "@swc/core-win32-x64-msvc": "1.13.5" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ=="],
"@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.13.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ=="],
"@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.13.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng=="],
"@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.13.5", "", { "os": "linux", "cpu": "arm" }, "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ=="],
"@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw=="],
"@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.13.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ=="],
"@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA=="],
"@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.13.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q=="],
"@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.13.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw=="],
"@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.13.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw=="],
"@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.13.5", "", { "os": "win32", "cpu": "x64" }, "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q=="],
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
"@swc/types": ["@swc/types@0.1.24", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-tjTMh3V4vAORHtdTprLlfoMptu1WfTZG9Rsca6yOKyNYsRr+MUXutKmliB17orgSZk5DpnDxs8GUdd/qwYxOng=="],
"@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.12", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.4.3" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.12", "@tailwindcss/oxide-darwin-arm64": "4.1.12", "@tailwindcss/oxide-darwin-x64": "4.1.12", "@tailwindcss/oxide-freebsd-x64": "4.1.12", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", "@tailwindcss/oxide-linux-x64-musl": "4.1.12", "@tailwindcss/oxide-wasm32-wasi": "4.1.12", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" } }, "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.12", "", { "os": "android", "cpu": "arm64" }, "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.12", "", { "os": "linux", "cpu": "arm" }, "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.12", "", { "os": "linux", "cpu": "x64" }, "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.12", "", { "cpu": "none" }, "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.12", "", { "os": "win32", "cpu": "x64" }, "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.12", "", { "dependencies": { "@tailwindcss/node": "4.1.12", "@tailwindcss/oxide": "4.1.12", "tailwindcss": "4.1.12" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.0", "", {}, "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ=="],
"@types/d3-scale": ["@types/d3-scale@4.0.8", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ=="],
"@types/d3-shape": ["@types/d3-shape@3.1.6", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA=="],
"@types/d3-time": ["@types/d3-time@3.0.3", "", {}, "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
"@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/node": ["@types/node@24.3.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow=="],
"@types/react": ["@types/react@19.1.11", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ=="],
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@4.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.32", "@swc/core": "^1.13.2" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-NQhPjysi5duItyrMd5JWZFf2vNOuSMyw+EoZyTBDzk+DkfYD8WNrsUs09sELV2cr1P15nufsN25hsUBt4CKF9Q=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.4", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": "cli.js" }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"chokidar": ["chokidar@3.5.1", "", { "dependencies": { "anymatch": "~3.1.1", "braces": "~3.0.2", "glob-parent": "~5.1.0", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.5.0" }, "optionalDependencies": { "fsevents": "~2.3.1" } }, "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
"cli-table": ["cli-table@0.3.11", "", { "dependencies": { "colors": "1.0.3" } }, "sha512-IqLQi4lO0nIB4tcdTpN4LCB9FI3uqrJZK7RC515EnhZ6qBaglkIgICb1wjeAqpdoOabm1+SuQtkXIPdYC93jhQ=="],
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"colors": ["colors@1.0.3", "", {}, "sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cosmiconfig": ["cosmiconfig@8.3.6", "", { "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0", "path-type": "^4.0.0" }, "peerDependencies": { "typescript": ">=4.9.5" } }, "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.182", "", {}, "sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="],
"esbuild": ["esbuild@0.25.6", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.6", "@esbuild/android-arm": "0.25.6", "@esbuild/android-arm64": "0.25.6", "@esbuild/android-x64": "0.25.6", "@esbuild/darwin-arm64": "0.25.6", "@esbuild/darwin-x64": "0.25.6", "@esbuild/freebsd-arm64": "0.25.6", "@esbuild/freebsd-x64": "0.25.6", "@esbuild/linux-arm": "0.25.6", "@esbuild/linux-arm64": "0.25.6", "@esbuild/linux-ia32": "0.25.6", "@esbuild/linux-loong64": "0.25.6", "@esbuild/linux-mips64el": "0.25.6", "@esbuild/linux-ppc64": "0.25.6", "@esbuild/linux-riscv64": "0.25.6", "@esbuild/linux-s390x": "0.25.6", "@esbuild/linux-x64": "0.25.6", "@esbuild/netbsd-arm64": "0.25.6", "@esbuild/netbsd-x64": "0.25.6", "@esbuild/openbsd-arm64": "0.25.6", "@esbuild/openbsd-x64": "0.25.6", "@esbuild/openharmony-arm64": "0.25.6", "@esbuild/sunos-x64": "0.25.6", "@esbuild/win32-arm64": "0.25.6", "@esbuild/win32-ia32": "0.25.6", "@esbuild/win32-x64": "0.25.6" }, "bin": "bin/esbuild" }, "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"foreground-child": ["foreground-child@3.2.1", "", { "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" } }, "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"glob": ["glob@11.0.1", "", { "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": "dist/esm/bin.mjs" }, "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@4.1.0", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" } }, "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw=="],
"jest-get-type": ["jest-get-type@29.6.3", "", {}, "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw=="],
"jest-validate": ["jest-validate@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "camelcase": "^6.2.0", "chalk": "^4.0.0", "jest-get-type": "^29.6.3", "leven": "^3.1.0", "pretty-format": "^29.7.0" } }, "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw=="],
"jiti": ["jiti@1.21.7", "", { "bin": "bin/jiti.js" }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"js-sha256": ["js-sha256@0.10.1", "", {}, "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@0.452.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-kNefjOUOGm+Mu3KDiryONyPba9r+nhcrz5oJs3N6JDzGboQNEXw5GB3yB8rnV9/FA4bPyggNU6CRSihZm9MvSw=="],
"magic-string": ["magic-string@0.30.18", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"minimatch": ["minimatch@10.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ=="],
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
"mkdirp": ["mkdirp@3.0.1", "", { "bin": "dist/cjs/src/bin.js" }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="],
"moo": ["moo@0.5.2", "", {}, "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@0.11.4", "", {}, "sha512-k1oiVNN4hDK8NcNERSZLQiMfRzEGtfnvZvdBvey3SQbgn8Dcrk0h1I6vpxApjb10PFUflZrgJ2WEZyJQ+5v7YQ=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.0", "", {}, "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@2.0.0", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg=="],
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pocketbase": ["pocketbase@0.26.2", "", {}, "sha512-WA8EOBc3QnSJh8rJ3iYoi9DmmPOMFIgVfAmIGux7wwruUEIzXgvrO4u0W2htfQjGIcyezJkdZOy5Xmh7SxAftw=="],
"pofile": ["pofile@1.1.4", "", {}, "sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"pseudolocale": ["pseudolocale@2.1.0", "", { "dependencies": { "commander": "^10.0.0" }, "bin": "dist/cli.mjs" }, "sha512-af5fsrRvVwD+MBasBJvuDChT0KDqT0nEwD9NTgbtHJ16FKomWac9ua0z6YVNB4G9x9IOaiGWym62aby6n4tFMA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-remove-scroll": ["react-remove-scroll@2.6.3", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@3.5.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ=="],
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="],
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
"rollup": ["rollup@4.48.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.48.1", "@rollup/rollup-android-arm64": "4.48.1", "@rollup/rollup-darwin-arm64": "4.48.1", "@rollup/rollup-darwin-x64": "4.48.1", "@rollup/rollup-freebsd-arm64": "4.48.1", "@rollup/rollup-freebsd-x64": "4.48.1", "@rollup/rollup-linux-arm-gnueabihf": "4.48.1", "@rollup/rollup-linux-arm-musleabihf": "4.48.1", "@rollup/rollup-linux-arm64-gnu": "4.48.1", "@rollup/rollup-linux-arm64-musl": "4.48.1", "@rollup/rollup-linux-loongarch64-gnu": "4.48.1", "@rollup/rollup-linux-ppc64-gnu": "4.48.1", "@rollup/rollup-linux-riscv64-gnu": "4.48.1", "@rollup/rollup-linux-riscv64-musl": "4.48.1", "@rollup/rollup-linux-s390x-gnu": "4.48.1", "@rollup/rollup-linux-x64-gnu": "4.48.1", "@rollup/rollup-linux-x64-musl": "4.48.1", "@rollup/rollup-win32-arm64-msvc": "4.48.1", "@rollup/rollup-win32-ia32-msvc": "4.48.1", "@rollup/rollup-win32-x64-msvc": "4.48.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.12", "", {}, "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA=="],
"tapable": ["tapable@2.2.3", "", {}, "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg=="],
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"tslib": ["tslib@2.6.3", "", {}, "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="],
"tw-animate-css": ["tw-animate-css@1.3.7", "", {}, "sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@0.42.1", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
"vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"@tailwindcss/node/jiti": ["jiti@2.5.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ=="],
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"path-scurry/lru-cache": ["lru-cache@11.0.2", "", {}, "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.0.1", "", {}, "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA=="],
}
}

BIN
internal/site/bun.lockb Executable file

Binary file not shown.

View File

@@ -2,7 +2,7 @@
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
<link rel="manifest" href="./static/manifest.json" />
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
<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" />

View File

@@ -24,7 +24,6 @@ export default defineConfig({
"tr",
"ru",
"sl",
"sr",
"sv",
"uk",
"vi",

View File

@@ -1,12 +1,12 @@
{
"name": "beszel",
"version": "0.17.0",
"version": "0.15.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beszel",
"version": "0.17.0",
"version": "0.15.3",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
@@ -39,8 +39,8 @@
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.26.2",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
@@ -111,7 +111,6 @@
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -987,29 +986,6 @@
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
"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": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1138,7 +1114,6 @@
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.20.12",
"@babel/runtime": "^7.20.13",
@@ -1231,6 +1206,30 @@
"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": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1244,6 +1243,65 @@
"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": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1292,7 +1350,6 @@
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@lingui/message-utils": "5.4.1"
@@ -3488,7 +3545,6 @@
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3499,7 +3555,6 @@
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -3551,9 +3606,9 @@
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3625,6 +3680,13 @@
"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": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3671,6 +3733,16 @@
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -3704,7 +3776,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -4415,13 +4486,13 @@
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
},
"engines": {
@@ -4465,30 +4536,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": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -4709,22 +4756,6 @@
"dev": true,
"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": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
@@ -4776,9 +4807,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -5149,9 +5180,9 @@
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
@@ -5295,22 +5326,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": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -5393,7 +5408,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
@@ -5553,33 +5567,6 @@
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -5603,7 +5590,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5745,26 +5731,24 @@
}
},
"node_modules/react": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz",
"integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==",
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.2",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==",
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.2"
"react": "^19.1.1"
}
},
"node_modules/react-is": {
@@ -6187,16 +6171,6 @@
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -6217,6 +6191,16 @@
"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": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -6232,9 +6216,9 @@
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6299,8 +6283,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.3",
@@ -6422,7 +6405,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6657,7 +6639,6 @@
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6809,23 +6790,6 @@
"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": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -6841,6 +6805,13 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -6854,10 +6825,20 @@
"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": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.18.0-beta.2",
"version": "0.15.3",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -46,8 +46,8 @@
"lucide-react": "^0.452.0",
"nanostores": "^0.11.4",
"pocketbase": "^0.26.2",
"react": "^19.1.2",
"react-dom": "^19.1.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
@@ -77,4 +77,4 @@
"optionalDependencies": {
"@esbuild/linux-arm64": "^0.21.5"
}
}
}

View File

@@ -61,11 +61,6 @@ export const ActiveAlerts = () => {
<AlertDescription>
{alert.name === "Status" ? (
<Trans>Connection is down</Trans>
) : info.invert ? (
<Trans>
Below {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
) : (
<Trans>
Exceeds {alert.value}

View File

@@ -1,4 +1,4 @@
import { msg, t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
@@ -36,9 +36,6 @@ import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/i
import { InputCopy } from "./ui/input-copy"
export function AddSystemButton({ className }: { className?: string }) {
if (isReadOnlyUser()) {
return null
}
const [open, setOpen] = useState(false)
const opened = useRef(false)
if (open) {
@@ -48,7 +45,10 @@ export function AddSystemButton({ className }: { className?: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
<Button
variant="outline"
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>
Add <span className="hidden sm:inline">System</span>
@@ -124,8 +124,6 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
}
}
const systemTranslation = t`System`
return (
<DialogContent
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
@@ -136,11 +134,7 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
<Tabs defaultValue={tab} onValueChange={setTab}>
<DialogHeader>
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
{system ? (
<Trans>Edit {{ foo: systemTranslation }}</Trans>
) : (
<Trans>Add {{ foo: systemTranslation }}</Trans>
)}
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
</DialogTitle>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="docker">Docker</TabsTrigger>

View File

@@ -245,23 +245,13 @@ export function AlertContent({
{!singleDescription && (
<div>
<p id={`v${name}`} className="text-sm block h-8">
{alertData.invert ? (
<Trans>
Average drops below{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
) : (
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
)}
<Trans>
Average exceeds{" "}
<strong className="text-foreground">
{value}
{alertData.unit}
</strong>
</Trans>
</p>
<div className="flex gap-3">
<Slider

View File

@@ -11,7 +11,6 @@ import {
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import { AxisDomain } from "recharts/types/util/types"
export type DataPoint = {
label: string
@@ -31,7 +30,6 @@ export default function AreaChartDefault({
domain,
legend,
itemSorter,
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
}: // logRender = false,
@@ -42,9 +40,8 @@ export default function AreaChartDefault({
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: AxisDomain
domain?: [number, number]
legend?: boolean
showTotal?: boolean
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
@@ -68,25 +65,18 @@ export default function AreaChartDefault({
"ps-4": hideYAxis,
})}
>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={chartData.systemStats}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<AreaChart reverseStackOrder={reverseStackOrder} accessibilityLayer data={chartData.systemStats} margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}>
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
)}
{!hideYAxis && <YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
@@ -97,7 +87,6 @@ export default function AreaChartDefault({
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
/>
}
/>
@@ -125,5 +114,5 @@ export default function AreaChartDefault({
</ChartContainer>
</div>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
}

View File

@@ -2,7 +2,7 @@
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
@@ -41,7 +41,7 @@ export default memo(function ContainerChart({
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = `${toFixedFloat(value, 2)}%`
const val = toFixedFloat(value, 2) + unit
return updateYAxisWidth(val)
}
} else {
@@ -78,7 +78,7 @@ export default memo(function ContainerChart({
return `${decimalString(value)} ${unit}`
}
} else {
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)} ${unit}`
}
// data function
if (isNetChart) {
@@ -124,7 +124,6 @@ export default memo(function ContainerChart({
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
domain={pinnedAxisDomain()}
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
@@ -140,7 +139,7 @@ export default memo(function ContainerChart({
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filteredKeys.has(key)

View File

@@ -61,7 +61,6 @@ export default memo(function MemChart({ chartData, showMax }: { chartData: Chart
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
showTotal={true}
/>
}
/>

View File

@@ -8,11 +8,10 @@ import {
ContainerIcon,
DatabaseBackupIcon,
FingerprintIcon,
HardDriveIcon,
LayoutDashboard,
LogsIcon,
MailIcon,
Server,
ServerIcon,
SettingsIcon,
UsersIcon,
} from "lucide-react"
@@ -82,15 +81,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
)}
<CommandGroup heading={t`Pages / Settings`}>
<CommandItem
keywords={["home"]}
keywords={["home", t`All Systems`]}
onSelect={() => {
navigate(basePath)
setOpen(false)
}}
>
<ServerIcon className="me-2 size-4" />
<LayoutDashboard className="me-2 size-4" />
<span>
<Trans>All Systems</Trans>
<Trans>Dashboard</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
@@ -110,18 +109,6 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "smart"))
setOpen(false)
}}
>
<HardDriveIcon className="me-2 size-4" />
<span>S.M.A.R.T.</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))

View File

@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
import { Button } from "@/components/ui/button"
import { $allSystemsById } from "@/lib/stores"
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
import { MaximizeIcon, RefreshCwIcon } from "lucide-react"
import { Separator } from "../ui/separator"
import { $router, Link } from "../router"
import { listenKeys } from "nanostores"
@@ -35,8 +35,7 @@ import { getPagePath } from "@nanostores/router"
const syntaxTheme = "github-dark-dimmed"
export default function ContainersTable({ systemId }: { systemId?: string }) {
const loadTime = Date.now()
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
const [data, setData] = useState<ContainerRecord[]>([])
const [sorting, setSorting] = useBrowserStorage<SortingState>(
`sort-c-${systemId ? 1 : 0}`,
[{ id: systemId ? "name" : "system", desc: false }],
@@ -48,61 +47,56 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
function fetchData(systemId?: string) {
const pbOptions = {
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
}
const fetchData = (lastXMs: number) => {
const updated = Date.now() - lastXMs
let filter: string
if (systemId) {
filter = pb.filter("system={:system} && updated > {:updated}", { system: systemId, updated })
} else {
filter = pb.filter("updated > {:updated}", { updated })
}
pb.collection<ContainerRecord>("containers")
.getList(0, 2000, {
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
...pbOptions,
filter,
})
.then(
({ items }) => {
if (items.length === 0) {
setData([]);
return;
.then(({ items }) => setData((curItems) => {
const containerIds = new Set(items.map(item => item.id))
const now = Date.now()
for (const item of curItems) {
if (!containerIds.has(item.id) && now - item.updated < 70_000) {
items.push(item)
}
setData((curItems) => {
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
const containerIds = new Set()
const newItems = []
for (const item of items) {
if (Math.abs(lastUpdated - item.updated) < 70_000) {
containerIds.add(item.id)
newItems.push(item)
}
}
for (const item of curItems ?? []) {
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
newItems.push(item)
}
}
return newItems
})
}
)
return items
}))
}
// initial load
fetchData(systemId)
fetchData(70_000)
// if no systemId, pull system containers after every system update
// if no systemId, poll every 10 seconds
if (!systemId) {
return $allSystemsById.listen((_value, _oldValue, systemId) => {
// exclude initial load of systems
if (Date.now() - loadTime > 500) {
fetchData(systemId)
}
})
// poll every 10 seconds
const intervalId = setInterval(() => fetchData(10_500), 10_000)
// clear interval on unmount
return () => clearInterval(intervalId)
}
// if systemId, fetch containers after the system is updated
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
fetchData(systemId)
const changeTime = Date.now()
setTimeout(() => fetchData(Date.now() - changeTime + 1000), 100)
})
}, [])
const table = useReactTable({
data: data ?? [],
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
data,
columns: containerChartCols.filter(col => systemId ? col.id !== "system" : true),
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@@ -155,113 +149,92 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
<Trans>Click on a container to view more information.</Trans>
</CardDescription>
</div>
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ps-4 pe-10 w-full"
/>
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="ms-auto px-4 w-full max-w-full md:w-64"
/>
</div>
</CardHeader>
<div className="rounded-md">
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
</Card>
)
}
const AllContainersTable = memo(function AllContainersTable({
table,
rows,
colLength,
data,
}: {
table: TableType<ContainerRecord>
rows: Row<ContainerRecord>[]
colLength: number
data: ContainerRecord[] | undefined
}) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const activeContainer = useRef<ContainerRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (container: ContainerRecord) => {
activeContainer.current = container
setSheetOpen(true)
}
const AllContainersTable = memo(
function AllContainersTable({ table, rows, colLength }: { table: TableType<ContainerRecord>; rows: Row<ContainerRecord>[]; colLength: number }) {
// The virtualizer will need a reference to the scrollable container element
const scrollRef = useRef<HTMLDivElement>(null)
const activeContainer = useRef<ContainerRecord | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const openSheet = (container: ContainerRecord) => {
activeContainer.current = container
setSheetOpen(true)
}
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: rows.length,
estimateSize: () => 54,
getScrollElement: () => scrollRef.current,
overscan: 5,
})
const virtualRows = virtualizer.getVirtualItems()
const 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))
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 ? (
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>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
<ContainerSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeContainer={activeContainer} />
</div>
)
})
)
}
)
async function getLogsHtml(container: ContainerRecord): Promise<string> {
try {
const [{ highlighter }, logsHtml] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ logs: string }>("/api/beszel/containers/logs", {
system: container.system,
container: container.id,
}),
])
const [{ highlighter }, logsHtml] = await Promise.all([import('@/lib/shiki'), pb.send<{ logs: string }>("/api/beszel/containers/logs", {
system: container.system,
container: container.id,
})])
return logsHtml.logs ? highlighter.codeToHtml(logsHtml.logs, { lang: "log", theme: syntaxTheme }) : t`No results.`
} catch (error) {
console.error(error)
@@ -271,16 +244,13 @@ async function getLogsHtml(container: ContainerRecord): Promise<string> {
async function getInfoHtml(container: ContainerRecord): Promise<string> {
try {
let [{ highlighter }, { info }] = await Promise.all([
import("@/lib/shiki"),
pb.send<{ info: string }>("/api/beszel/containers/info", {
system: container.system,
container: container.id,
}),
])
let [{ highlighter }, { info }] = await Promise.all([import('@/lib/shiki'), pb.send<{ info: string }>("/api/beszel/containers/info", {
system: container.system,
container: container.id,
})])
try {
info = JSON.stringify(JSON.parse(info), null, 2)
} catch (_) {}
} catch (_) { }
return info ? highlighter.codeToHtml(info, { lang: "json", theme: syntaxTheme }) : t`No results.`
} catch (error) {
console.error(error)
@@ -288,15 +258,7 @@ async function getInfoHtml(container: ContainerRecord): Promise<string> {
}
}
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
if (!container) return null
@@ -335,14 +297,14 @@ function ContainerSheet({
useEffect(() => {
setLogsDisplay("")
setInfoDisplay("")
setInfoDisplay("");
if (!container) return
;(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20)
})()
(async () => {
const [logsHtml, infoHtml] = await Promise.all([getLogsHtml(container), getInfoHtml(container)])
setLogsDisplay(logsHtml)
setInfoDisplay(infoHtml)
setTimeout(scrollLogsToBottom, 20)
})()
}, [container])
return (
@@ -366,9 +328,7 @@ function ContainerSheet({
<SheetHeader>
<SheetTitle>{container.name}</SheetTitle>
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>
{$allSystemsById.get()[container.system]?.name ?? ""}
</Link>
<Link className="hover:underline" href={getPagePath($router, "system", { id: container.system })}>{$allSystemsById.get()[container.system]?.name ?? ""}</Link>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
{container.status}
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
@@ -390,20 +350,19 @@ function ContainerSheet({
disabled={isRefreshingLogs}
>
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? "animate-spin" : ""}`}
className={`size-4 transition-transform duration-300 ${isRefreshingLogs ? 'animate-spin' : ''}`}
/>
</Button>
<Button variant="ghost" size="sm" onClick={() => setLogsFullscreenOpen(true)} className="h-8 w-8 p-0">
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFullscreenOpen(true)}
className="h-8 w-8 p-0"
>
<MaximizeIcon className="size-4" />
</Button>
</div>
<div
ref={logsContainerRef}
className={cn(
"max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
!logsDisplay && ["animate-pulse", "h-full"]
)}
>
<div ref={logsContainerRef} className={cn("max-h-[calc(50dvh-10rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm", !logsDisplay && ["animate-pulse", "h-full"])}>
<div dangerouslySetInnerHTML={{ __html: logsDisplay }} />
</div>
<div className="flex items-center w-full">
@@ -417,18 +376,15 @@ function ContainerSheet({
<MaximizeIcon className="size-4" />
</Button>
</div>
<div
className={cn(
"grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm",
!infoDisplay && "animate-pulse"
)}
>
<div className={cn("grow h-[calc(50dvh-4rem)] w-full overflow-auto p-3 rounded-md bg-gh-dark text-white text-sm", !infoDisplay && "animate-pulse")}>
<div dangerouslySetInnerHTML={{ __html: infoDisplay }} />
</div>
</div>
</SheetContent>
</Sheet>
</>
)
}
@@ -450,51 +406,39 @@ function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
)
}
const ContainerTableRow = memo(function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
})
const ContainerTableRow = memo(
function ContainerTableRow({
row,
virtualRow,
openSheet,
}: {
row: Row<ContainerRecord>
virtualRow: VirtualItem
openSheet: (container: ContainerRecord) => void
}) {
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer transition-opacity"
onClick={() => openSheet(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="py-0"
style={{
height: virtualRow.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
)
function LogsFullscreenDialog({
open,
onOpenChange,
logsDisplay,
containerName,
onRefresh,
isRefreshing,
}: {
open: boolean
onOpenChange: (open: boolean) => void
logsDisplay: string
containerName: string
onRefresh: () => void | Promise<void>
isRefreshing: boolean
}) {
function LogsFullscreenDialog({ open, onOpenChange, logsDisplay, containerName, onRefresh, isRefreshing }: { open: boolean, onOpenChange: (open: boolean) => void, logsDisplay: string, containerName: string, onRefresh: () => void | Promise<void>, isRefreshing: boolean }) {
const outerContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@@ -519,30 +463,24 @@ function LogsFullscreenDialog({
</div>
</div>
<button
onClick={onRefresh}
onClick={() => {
void onRefresh()
}}
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
disabled={isRefreshing}
title={t`Refresh`}
aria-label={t`Refresh`}
>
<RefreshCwIcon className={`size-4 transition-transform duration-300 ${isRefreshing ? "animate-spin" : ""}`} />
<RefreshCwIcon
className={`size-4 transition-transform duration-300 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
</DialogContent>
</Dialog>
)
}
function InfoFullscreenDialog({
open,
onOpenChange,
infoDisplay,
containerName,
}: {
open: boolean
onOpenChange: (open: boolean) => void
infoDisplay: string
containerName: string
}) {
function InfoFullscreenDialog({ open, onOpenChange, infoDisplay, containerName }: { open: boolean, onOpenChange: (open: boolean) => void, infoDisplay: string, containerName: string }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-20px)] h-[calc(100dvh-20px)] max-w-none p-0 bg-gh-dark border-0 text-white">

View File

@@ -3,7 +3,6 @@ import { getPagePath } from "@nanostores/router"
import {
ContainerIcon,
DatabaseBackupIcon,
HardDriveIcon,
LogOutIcon,
LogsIcon,
SearchIcon,
@@ -30,7 +29,6 @@ import { LangToggle } from "./lang-toggle"
import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router"
import { t } from "@lingui/core/macro"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -57,13 +55,6 @@ export default function Navbar() {
>
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<Link
href={getPagePath($router, "smart")}
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
aria-label="S.M.A.R.T."
>
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
</Link>
<LangToggle />
<ModeToggle />
<Link

View File

@@ -3,7 +3,6 @@ import { createRouter } from "@nanostores/router"
const routes = {
home: "/",
containers: "/containers",
smart: "/smart",
system: `/system/:id`,
settings: `/settings/:name?`,
forgot_password: `/forgot-password`,

View File

@@ -7,7 +7,6 @@ import {
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
type VisibilityState,
@@ -41,7 +40,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { useToast } from "@/components/ui/use-toast"
import { alertInfo } from "@/lib/alerts"
import { pb } from "@/lib/api"
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
import type { AlertsHistoryRecord } from "@/types"
import { alertsHistoryColumns } from "../../alerts-history-columns"
@@ -67,12 +66,6 @@ export default function AlertsHistoryDataTable() {
const [globalFilter, setGlobalFilter] = useState("")
const { toast } = useToast()
const [deleteOpen, setDeleteDialogOpen] = useState(false)
// Store pagination preference in local storage
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
pageIndex: 0,
pageSize: 10,
})
useEffect(() => {
let unsubscribe: (() => void) | undefined
@@ -143,14 +136,12 @@ export default function AlertsHistoryDataTable() {
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
pagination,
},
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: (row, _columnId, filterValue) => {
@@ -327,10 +318,10 @@ export default function AlertsHistoryDataTable() {
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value));
table.setPageSize(Number(value))
}}
>
<SelectTrigger className="w-18" id="rows-per-page">
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side="top">

View File

@@ -2,17 +2,14 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { useState } from "react"
import { useStore } from "@nanostores/react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import Slider from "@/components/ui/slider"
import { HourFormat, Unit } from "@/lib/enums"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
import { $userSettings } from "@/lib/stores"
import { chartTimeData, currentHour12 } from "@/lib/utils"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
@@ -20,8 +17,6 @@ import { saveSettings } from "./layout"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
const [isLoading, setIsLoading] = useState(false)
const { i18n } = useLingui()
const currentUserSettings = useStore($userSettings)
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -78,27 +73,6 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
</Select>
</div>
<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="mb-2">
<h3 className="mb-1 text-lg font-medium">

View File

@@ -14,7 +14,6 @@ import { toast } from "@/components/ui/use-toast"
import { isAdmin, pb } from "@/lib/api"
import type { UserSettings } from "@/types"
import { saveSettings } from "./layout"
import { QuietHours } from "./quiet-hours"
interface ShoutrrrUrlCardProps {
url: string
@@ -121,32 +120,19 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
</div>
<Separator />
<div className="space-y-3">
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
<div>
<h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
Shoutrrr
</a>{" "}
to integrate with popular notification services.
</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>
<h3 className="mb-1 text-lg font-medium">
<Trans>Webhook / Push notifications</Trans>
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
<Trans>
Beszel uses{" "}
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link" rel="noopener">
Shoutrrr
</a>{" "}
to integrate with popular notification services.
</Trans>
</p>
</div>
{webhooks.length > 0 && (
<div className="grid gap-2.5" id="webhooks">
@@ -160,10 +146,16 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
))}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<QuietHours />
<Button
type="button"
variant="outline"
size="sm"
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>
<Separator />
<Button
@@ -202,7 +194,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
}
return (
<Card className="bg-table-header p-2 md:p-3">
<Card className="bg-muted/40 p-2 md:p-3">
<div className="flex items-center gap-1">
<Input
type="url"

View File

@@ -1,534 +0,0 @@
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>
)
}

View File

@@ -1,20 +0,0 @@
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 />
</>
)
}

View File

@@ -3,7 +3,15 @@ import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time"
import { XIcon } from "lucide-react"
import {
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MonitorIcon,
XIcon,
} from "lucide-react"
import { subscribeKeys } from "nanostores"
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
@@ -16,7 +24,7 @@ import MemChart from "@/components/charts/mem-chart"
import SwapChart from "@/components/charts/swap-chart"
import TemperatureChart from "@/components/charts/temperature-chart"
import { getPbTimestamp, pb } from "@/lib/api"
import { ChartType, SystemStatus, Unit } from "@/lib/enums"
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
import { batteryStateTranslations } from "@/lib/i18n"
import {
$allSystemsById,
@@ -34,8 +42,11 @@ import {
chartTimeData,
cn,
compareSemVer,
debounce,
decimalString,
formatBytes,
secondsToString,
getHostDisplayValue,
listen,
parseSemVer,
toFixedFloat,
@@ -46,24 +57,26 @@ import type {
ChartTimes,
ContainerStatsRecord,
GPUData,
SystemDetailsRecord,
SystemInfo,
SystemRecord,
SystemStats,
SystemStatsRecord,
} from "@/types"
import ChartTimeSelect from "../charts/chart-time-select"
import { $router, navigate } from "../router"
import Spinner from "../spinner"
import { Button } from "../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { ChartAverage, ChartMax } from "../ui/icons"
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
import { Input } from "../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart"
import { pinnedAxisDomain } from "../ui/chart"
import InfoBar from "./system/info-bar"
type ChartTimeData = {
time: number
@@ -143,8 +156,8 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
})
}
function dockerOrPodman(str: string, isPodman: boolean): string {
if (isPodman) {
function dockerOrPodman(str: string, system: SystemRecord): string {
if (system.info.p) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
@@ -167,7 +180,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
const userSettings = $userSettings.get()
const chartWrapRef = useRef<HTMLDivElement>(null)
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
useEffect(() => {
return () => {
@@ -177,7 +189,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
}
}, [id])
@@ -205,25 +216,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}
}, [system?.info?.v])
// fetch system details
useEffect(() => {
// if system.info.m exists, agent is old version without system details
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then(setDetails)
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
let unsub = () => {}
let unsub = () => { }
if (!system.id || chartTime !== "1m") {
return
}
@@ -259,6 +255,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}
}, [chartTime, system.id])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
@@ -298,6 +295,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
}, [])
// get stats
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
useEffect(() => {
if (!system.id || !chartTime || chartTime === "1m") {
return
@@ -337,6 +335,63 @@ export default memo(function SystemDetail({ id }: { id: string }) {
})
}, [system, chartTime])
// values for system info bar
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
value: system.info.k,
label: t({ comment: "Linux kernel", message: "Kernel" }),
},
[Os.Darwin]: {
Icon: AppleIcon,
value: `macOS ${system.info.k}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: system.info.k,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: system.info.k,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} else {
uptime = secondsToString(system.info.u, "day")
}
return [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: system.info.h,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[system.info.os ?? Os.Linux],
{
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
Icon: CpuIcon,
hide: !system.info.m,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
}, [system, t])
/** Space for tooltip if more than 10 sensors and no containers table */
useEffect(() => {
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
@@ -404,14 +459,114 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpuData = lastGpuVals.length > 0
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
const isLinux = !(details?.os ?? system.info?.os)
const isPodman = details?.podman ?? system.info?.p ?? false
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<>
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
{/* system info */}
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} details={details} />
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
{/* <Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full h-11">
@@ -423,6 +578,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</TabsContent>
</Tabs> */}
{/* main charts */}
<div className="grid xl:grid-cols-2 gap-4">
<ChartCard
@@ -450,7 +606,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
/>
</ChartCard>
@@ -458,7 +613,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
title={dockerOrPodman(t`Docker CPU Usage`, system)}
description={t`Average CPU utilization of containers`}
cornerEl={containerFilterBar}
>
@@ -485,8 +640,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
title={dockerOrPodman(t`Docker Memory Usage`, system)}
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart
@@ -544,7 +699,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
@@ -598,7 +752,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
@@ -606,8 +759,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
title={dockerOrPodman(t`Docker Network I/O`, system)}
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
cornerEl={containerFilterBar}
>
<ContainerChart
@@ -646,7 +799,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{/* Temperature chart */}
{systemStats.at(-1)?.stats.t && (
<div ref={temperatureChartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<div
ref={temperatureChartRef}
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
>
<ChartCard
empty={dataEmpty}
grid={grid}
@@ -808,9 +964,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
label: t`Write`,
dataKey: ({ stats }) => {
if (showMax) {
return (
stats?.efs?.[extraFsName]?.wbm || (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
},
@@ -848,14 +1002,12 @@ export default memo(function SystemDetail({ id }: { id: string }) {
</div>
)}
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && <LazySmartTable systemId={system.id} />}
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={system.id} />
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
<LazySmartTable systemId={system.id} />
)}
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
<LazySystemdTable systemId={system.id} />
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
<LazyContainersTable systemId={id} />
)}
</div>
@@ -888,31 +1040,15 @@ function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
}
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const storeValue = useStore(store)
const [inputValue, setInputValue] = useState(storeValue)
const containerFilter = useStore(store)
const { t } = useLingui()
useEffect(() => {
setInputValue(storeValue)
}, [storeValue])
const debouncedStoreSet = useMemo(() => debounce((value: string) => store.set(value), 80), [store])
useEffect(() => {
if (inputValue === storeValue) {
return
}
const handle = window.setTimeout(() => store.set(inputValue), 80)
return () => clearTimeout(handle)
}, [inputValue, storeValue, store])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}, [])
const handleClear = useCallback(() => {
setInputValue("")
store.set("")
}, [store])
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => debouncedStoreSet(e.target.value),
[debouncedStoreSet]
)
return (
<>
@@ -920,16 +1056,16 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}
value={containerFilter}
/>
{inputValue && (
{containerFilter && (
<Button
type="button"
variant="ghost"
size="icon"
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"
onClick={handleClear}
onClick={() => store.set("")}
>
<XIcon className="h-4 w-4" />
</Button>
@@ -1023,15 +1159,4 @@ function LazySmartTable({ systemId }: { systemId: string }) {
{isIntersecting && <SmartTable systemId={systemId} />}
</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>
)
}
}

View File

@@ -1,229 +0,0 @@
import { plural } from "@lingui/core/macro"
import { useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MemoryStickIcon,
MonitorIcon,
Rows,
} from "lucide-react"
import { useMemo } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { ConnectionType, connectionTypeLabels, Os, SystemStatus } from "@/lib/enums"
import { cn, formatBytes, getHostDisplayValue, secondsToString, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemDetailsRecord, SystemRecord } from "@/types"
export default function InfoBar({
system,
chartData,
grid,
setGrid,
details,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
details: SystemDetailsRecord | null
}) {
const { t } = useLingui()
// values for system info bar - use details with fallback to system.info
const systemInfo = useMemo(() => {
if (!system.info) {
return []
}
// Use details if available, otherwise fall back to system.info
const hostname = details?.hostname ?? system.info.h
const kernel = details?.kernel ?? system.info.k
const cores = details?.cores ?? system.info.c
const threads = details?.threads ?? system.info.t ?? 0
const cpuModel = details?.cpu ?? system.info.m
const os = details?.os ?? system.info.os ?? Os.Linux
const osName = details?.os_name
const arch = details?.arch
const memory = details?.memory
const osInfo = {
[Os.Linux]: {
Icon: TuxIcon,
// show kernel in tooltip if os name is available, otherwise show the kernel
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.Darwin]: {
Icon: AppleIcon,
value: osName || `macOS ${kernel}`,
},
[Os.Windows]: {
Icon: WindowsIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
[Os.FreeBSD]: {
Icon: FreeBsdIcon,
value: osName || kernel,
label: osName ? kernel : undefined,
},
}
let uptime: string
if (system.info.u < 3600) {
uptime = secondsToString(system.info.u, "minute")
} else if (system.info.u < 360000) {
uptime = secondsToString(system.info.u, "hour")
} else {
uptime = secondsToString(system.info.u, "day")
}
const info = [
{ value: getHostDisplayValue(system), Icon: GlobeIcon },
{
value: hostname,
Icon: MonitorIcon,
label: "Hostname",
// hide if hostname is same as host or name
hide: hostname === system.host || hostname === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
osInfo[os],
{
value: cpuModel,
Icon: CpuIcon,
hide: !cpuModel,
label: `${plural(cores, { one: "# core", other: "# cores" })} / ${plural(threads, { one: "# thread", other: "# threads" })}${arch ? ` / ${arch}` : ""}`,
},
] as {
value: string | number | undefined
label?: string
Icon: React.ElementType
hide?: boolean
}[]
if (memory) {
const memValue = formatBytes(memory, false, undefined, false)
info.push({
value: `${toFixedFloat(memValue.value, memValue.value >= 10 ? 1 : 2)} ${memValue.unit}`,
Icon: MemoryStickIcon,
hide: !memory,
label: t`Memory`,
})
}
return info
}, [system, details, t])
let translatedStatus: string = system.status
if (system.status === SystemStatus.Up) {
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
} else if (system.status === SystemStatus.Down) {
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
}
return (
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
<span className={cn("relative flex h-3 w-3")}>
{system.status === SystemStatus.Up && (
<span
className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
style={{ animationDuration: "1.5s" }}
></span>
)}
<span
className={cn("relative inline-flex rounded-full h-3 w-3", {
"bg-green-500": system.status === SystemStatus.Up,
"bg-red-500": system.status === SystemStatus.Down,
"bg-primary/40": system.status === SystemStatus.Paused,
"bg-yellow-500": system.status === SystemStatus.Pending,
})}
></span>
</span>
{translatedStatus}
</div>
</TooltipTrigger>
{system.info.ct && (
<TooltipContent>
<div className="flex gap-1 items-center">
{system.info.ct === ConnectionType.WebSocket ? (
<WebSocketIcon className="size-4" />
) : (
<ChevronRightSquareIcon className="size-4" strokeWidth={2} />
)}
{connectionTypeLabels[system.info.ct as ConnectionType]}
</div>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{systemInfo.map(({ value, label, Icon, hide }) => {
if (hide || !value) {
return null
}
const content = (
<div className="flex gap-1.5 items-center">
<Icon className="h-4 w-4" /> {value}
</div>
)
return (
<div key={value} className="contents">
<Separator orientation="vertical" className="h-4 bg-primary/30" />
{label ? (
<TooltipProvider>
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
content
)}
</div>
)
})}
</div>
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={t`Toggle grid`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</Card>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,200 +0,0 @@
import type { Column, ColumnDef } from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { cn, decimalString, formatBytes, hourWithSeconds } from "@/lib/utils"
import type { 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"
}
}

View File

@@ -1,667 +0,0 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type Row,
type SortingState,
type Table as TableType,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table"
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { 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>
)
})

View File

@@ -1,4 +1,3 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
@@ -17,14 +16,12 @@ import {
PenBoxIcon,
PlayCircleIcon,
ServerIcon,
TerminalSquareIcon,
Trash2Icon,
WifiIcon,
} from "lucide-react"
import { memo, useMemo, useRef, useState } from "react"
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
import { isReadOnlyUser, pb } from "@/lib/api"
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
import {
cn,
@@ -35,7 +32,6 @@ import {
getMeterState,
parseSemVer,
} from "@/lib/utils"
import { batteryStateTranslations } from "@/lib/i18n"
import type { SystemRecord } from "@/types"
import { SystemDialog } from "../add-system"
import AlertButton from "../alerts/alert-button"
@@ -59,18 +55,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import {
BatteryMediumIcon,
EthernetIcon,
GpuIcon,
HourglassIcon,
ThermometerIcon,
WebSocketIcon,
BatteryHighIcon,
BatteryLowIcon,
PlugChargingIcon,
BatteryFullIcon,
} from "../ui/icons"
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons"
const STATUS_COLORS = {
[SystemStatus.Up]: "bg-green-500",
@@ -83,7 +68,7 @@ const STATUS_COLORS = {
* @param viewMode - "table" or "grid"
* @returns - Column definitions for the systems table
*/
export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
return [
{
// size: 200,
@@ -148,7 +133,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.cpu || undefined,
accessorFn: ({ info }) => info.cpu,
id: "cpu",
name: () => t`CPU`,
cell: TableCellWithMeter,
@@ -157,7 +142,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
},
{
// accessorKey: "info.mp",
accessorFn: ({ info }) => info.mp || undefined,
accessorFn: ({ info }) => info.mp,
id: "memory",
name: () => t`Memory`,
cell: TableCellWithMeter,
@@ -165,15 +150,15 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.dp || undefined,
accessorFn: ({ info }) => info.dp,
id: "disk",
name: () => t`Disk`,
cell: DiskCellWithMultiple,
cell: TableCellWithMeter,
Icon: HardDriveIcon,
header: sortableHeader,
},
{
accessorFn: ({ info }) => info.g || undefined,
accessorFn: ({ info }) => info.g,
id: "gpu",
name: () => "GPU",
cell: TableCellWithMeter,
@@ -186,9 +171,9 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
// TODO: remove this in future release in favor of la array
if (!sum) {
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
}
return sum || undefined
return sum
},
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
size: 0,
@@ -231,7 +216,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
},
},
{
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
id: "net",
name: () => t`Net`,
size: 0,
@@ -243,7 +228,7 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
if (sys.status === SystemStatus.Paused) {
return null
}
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
return (
<span className="tabular-nums whitespace-nowrap">
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
@@ -273,95 +258,11 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
)
},
},
{
accessorFn: ({ info }) => info.bat?.[0],
id: "battery",
name: () => t({ message: "Bat", comment: "Battery label in systems table header" }),
size: 70,
Icon: BatteryMediumIcon,
header: sortableHeader,
hideSort: true,
cell(info) {
const [pct, state] = info.row.original.info.bat ?? []
if (pct === undefined) {
return null
}
let Icon = PlugChargingIcon
let iconColor = "text-muted-foreground"
if (state !== BatteryState.Charging) {
if (pct < 25) {
iconColor = pct < 11 ? "text-red-500" : "text-yellow-500"
Icon = BatteryLowIcon
} else if (pct < 75) {
Icon = BatteryMediumIcon
} else if (pct < 95) {
Icon = BatteryHighIcon
} else {
Icon = BatteryFullIcon
}
}
const stateLabel =
state !== undefined ? (batteryStateTranslations[state as BatteryState]?.() ?? undefined) : undefined
return (
<Link
tabIndex={-1}
href={getPagePath($router, "system", { id: info.row.original.id })}
className="flex items-center gap-1 tabular-nums tracking-tight relative z-10"
title={stateLabel}
>
<Icon className={cn("size-3.5", iconColor)} />
<span className="min-w-10">{pct}%</span>
</Link>
)
},
},
{
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,
id: "agent",
name: () => t`Agent`,
// invertSorting: true,
size: 50,
Icon: WifiIcon,
hideSort: true,
@@ -453,101 +354,6 @@ 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 }) {
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
return (
@@ -657,5 +463,5 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
</AlertDialog>
</>
)
}, [id, status, host, name, system, t, deleteOpen, editOpen])
}, [id, status, host, name, t, deleteOpen, editOpen])
})

Some files were not shown because too many files have changed in this diff Show More