mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 05:56:17 +01:00
Compare commits
89 Commits
extra-disk
...
v0.18.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f067aad5b | ||
|
|
1388711105 | ||
|
|
618e5b4cc1 | ||
|
|
42c3ca5db5 | ||
|
|
534791776b | ||
|
|
0c6c53fc7d | ||
|
|
0dfd5ce07d | ||
|
|
2cd6d46f7c | ||
|
|
c333a9fadd | ||
|
|
ba3d1c66f0 | ||
|
|
7276e533ce | ||
|
|
8b84231042 | ||
|
|
77da744008 | ||
|
|
5da7a21119 | ||
|
|
78d742c712 | ||
|
|
1c97ea3e2c | ||
|
|
3d970defe9 | ||
|
|
6282794004 | ||
|
|
475c53a55d | ||
|
|
4547ff7b5d | ||
|
|
e7b4be3dc5 | ||
|
|
2bd85e04fc | ||
|
|
f6ab5f2af1 | ||
|
|
7d943633a3 | ||
|
|
7fff3c999a | ||
|
|
a9068a11a9 | ||
|
|
d3d102516c | ||
|
|
32131439f9 | ||
|
|
d17685c540 | ||
|
|
e59f8eee36 | ||
|
|
35329abcbd | ||
|
|
ee7741c3ab | ||
|
|
ab0803b2da | ||
|
|
96196a353c | ||
|
|
2a8796c38d | ||
|
|
c8d4f7427d | ||
|
|
8d41a797d3 | ||
|
|
570e1cbf40 | ||
|
|
4c9b00a066 | ||
|
|
7d1f8bb180 | ||
|
|
3a6caeb06e | ||
|
|
9e67245e60 | ||
|
|
b7a95d5d76 | ||
|
|
fe550c5901 | ||
|
|
8aac0a571a | ||
|
|
c506b8b0ad | ||
|
|
a6e84c207e | ||
|
|
249402eaed | ||
|
|
394c476f2a | ||
|
|
86e8a141ea | ||
|
|
53a7e06dcf | ||
|
|
11edabd09f | ||
|
|
41a3d9359f | ||
|
|
5dfc5f247f | ||
|
|
9804c8a31a | ||
|
|
4d05bfdff0 | ||
|
|
0388401a9e | ||
|
|
162c548010 | ||
|
|
888b4a57e5 | ||
|
|
26d367b188 | ||
|
|
ca4988951f | ||
|
|
c7a50dd74d | ||
|
|
00fbf5c9c3 | ||
|
|
4bfe9dd5ad | ||
|
|
e159a75b79 | ||
|
|
a69686125e | ||
|
|
3eb025ded2 | ||
|
|
1d0e646094 | ||
|
|
32c8e047e3 | ||
|
|
3650482b09 | ||
|
|
79adfd2c0d | ||
|
|
779dcc62aa | ||
|
|
abe39c1a0a | ||
|
|
bd41ad813c | ||
|
|
77fe63fb63 | ||
|
|
f61ba202d8 | ||
|
|
e1067fa1a3 | ||
|
|
0a3eb898ae | ||
|
|
6c33e9dc93 | ||
|
|
f8ed6ce705 | ||
|
|
f64478b75e | ||
|
|
854a3697d7 | ||
|
|
b7915b9d0e | ||
|
|
4443b606f6 | ||
|
|
6c20a98651 | ||
|
|
b722ccc5bc | ||
|
|
db0315394b | ||
|
|
a7ef1235f4 | ||
|
|
f64a361c60 |
40
.github/workflows/docker-images.yml
vendored
40
.github/workflows/docker-images.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
|
max-parallel: 5
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# henrygd/beszel
|
# henrygd/beszel
|
||||||
@@ -25,18 +26,17 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=alpine
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}-alpine
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}-alpine
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
|
||||||
|
|
||||||
# henrygd/beszel-agent-nvidia
|
# henrygd/beszel-agent-nvidia
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
@@ -66,18 +66,6 @@ jobs:
|
|||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
# henrygd/beszel-agent:alpine
|
|
||||||
- image: henrygd/beszel-agent
|
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
|
||||||
registry: docker.io
|
|
||||||
username_secret: DOCKERHUB_USERNAME
|
|
||||||
password_secret: DOCKERHUB_TOKEN
|
|
||||||
tags: |
|
|
||||||
type=raw,value=alpine
|
|
||||||
type=semver,pattern={{version}}-alpine
|
|
||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
|
||||||
type=semver,pattern={{major}}-alpine
|
|
||||||
|
|
||||||
# ghcr.io/henrygd/beszel
|
# ghcr.io/henrygd/beszel
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel
|
- image: ghcr.io/${{ github.repository }}/beszel
|
||||||
dockerfile: ./internal/dockerfile_hub
|
dockerfile: ./internal/dockerfile_hub
|
||||||
@@ -99,6 +87,7 @@ jobs:
|
|||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
|
type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -144,6 +133,19 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-alpine
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
|
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
||||||
|
- image: henrygd/beszel-agent
|
||||||
|
dockerfile: ./internal/dockerfile_agent
|
||||||
|
registry: docker.io
|
||||||
|
username_secret: DOCKERHUB_USERNAME
|
||||||
|
password_secret: DOCKERHUB_TOKEN
|
||||||
|
tags: |
|
||||||
|
type=raw,value=edge
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value={{sha}},enable=${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|||||||
17
.github/workflows/inactivity-actions.yml
vendored
17
.github/workflows/inactivity-actions.yml
vendored
@@ -10,12 +10,25 @@ permissions:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lock-inactive:
|
||||||
|
name: Lock Inactive Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: klaasnicolaas/action-inactivity-lock@v1.1.3
|
||||||
|
id: lock
|
||||||
|
with:
|
||||||
|
days-inactive-issues: 14
|
||||||
|
lock-reason-issues: ""
|
||||||
|
# Action can not skip PRs, set it to 100 years to cover it.
|
||||||
|
days-inactive-prs: 36524
|
||||||
|
lock-reason-prs: ""
|
||||||
|
|
||||||
close-stale:
|
close-stale:
|
||||||
name: Close Stale Issues
|
name: Close Stale Issues
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Close Stale Issues
|
- name: Close Stale Issues
|
||||||
uses: actions/stale@v9
|
uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -32,6 +45,8 @@ jobs:
|
|||||||
# Timing
|
# Timing
|
||||||
days-before-issue-stale: 14
|
days-before-issue-stale: 14
|
||||||
days-before-issue-close: 7
|
days-before-issue-close: 7
|
||||||
|
# Action can not skip PRs, set it to 100 years to cover it.
|
||||||
|
days-before-pr-stale: 36524
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
stale-issue-label: 'stale'
|
stale-issue-label: 'stale'
|
||||||
|
|||||||
@@ -16,10 +16,21 @@ builds:
|
|||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
- windows
|
||||||
|
- freebsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- arm
|
- arm
|
||||||
|
ignore:
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
binary: beszel-agent
|
binary: beszel-agent
|
||||||
@@ -86,6 +97,9 @@ archives:
|
|||||||
{{ .Binary }}_
|
{{ .Binary }}_
|
||||||
{{- .Os }}_
|
{{- .Os }}_
|
||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
formats: [zip]
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
@@ -29,12 +31,15 @@ type Agent struct {
|
|||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
|
||||||
|
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
||||||
|
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -69,6 +74,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
agent.sensorConfig = agent.newSensorConfig()
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
|
|
||||||
|
// Parse disk usage cache duration (e.g., "15m", "1h") to avoid waking sleeping disks
|
||||||
|
if diskUsageCache, exists := GetEnv("DISK_USAGE_CACHE"); exists {
|
||||||
|
if duration, err := time.ParseDuration(diskUsageCache); err == nil {
|
||||||
|
agent.diskUsageCacheDuration = duration
|
||||||
|
slog.Info("DISK_USAGE_CACHE", "duration", duration)
|
||||||
|
} else {
|
||||||
|
slog.Warn("Invalid DISK_USAGE_CACHE", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set up slog with a log level determined by the LOG_LEVEL env var
|
// Set up slog with a log level determined by the LOG_LEVEL env var
|
||||||
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
|
||||||
switch strings.ToLower(logLevelStr) {
|
switch strings.ToLower(logLevelStr) {
|
||||||
@@ -84,8 +100,21 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
|
// initialize docker manager
|
||||||
|
agent.dockerManager = newDockerManager()
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.initializeSystemInfo()
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// initialize connection manager
|
// initialize connection manager
|
||||||
agent.connectionManager = newConnectionManager(agent)
|
agent.connectionManager = newConnectionManager(agent)
|
||||||
@@ -99,9 +128,6 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
// initialize net io stats
|
// initialize net io stats
|
||||||
agent.initializeNetIoStats()
|
agent.initializeNetIoStats()
|
||||||
|
|
||||||
// initialize docker manager
|
|
||||||
agent.dockerManager = newDockerManager(agent)
|
|
||||||
|
|
||||||
agent.systemdManager, err = newSystemdManager()
|
agent.systemdManager, err = newSystemdManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Systemd", "err", err)
|
slog.Debug("Systemd", "err", err)
|
||||||
@@ -120,7 +146,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(0))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -135,10 +161,11 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
|
|
||||||
|
cacheTimeMs := options.CacheTimeMs
|
||||||
data, isCached := a.cache.Get(cacheTimeMs)
|
data, isCached := a.cache.Get(cacheTimeMs)
|
||||||
if isCached {
|
if isCached {
|
||||||
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
slog.Debug("Cached data", "cacheTimeMs", cacheTimeMs)
|
||||||
@@ -149,6 +176,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
Stats: a.getSystemStats(cacheTimeMs),
|
Stats: a.getSystemStats(cacheTimeMs),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Include static system details only when requested
|
||||||
|
if options.IncludeDetails {
|
||||||
|
data.Details = &a.systemDetails
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -161,9 +194,16 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 && a.systemdManager.hasFreshStats {
|
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.SystemdServices = a.systemdManager.getServiceStats(nil, false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
data.Info.ExtraFsPct = make(map[string]float64)
|
||||||
@@ -175,7 +215,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
// Add percentage info to Info struct for dashboard
|
// Add percentages to Info struct for dashboard
|
||||||
if stats.DiskTotal > 0 {
|
if stats.DiskTotal > 0 {
|
||||||
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
pct := twoDecimals((stats.DiskUsed / stats.DiskTotal) * 100)
|
||||||
data.Info.ExtraFsPct[key] = pct
|
data.Info.ExtraFsPct[key] = pct
|
||||||
@@ -204,8 +244,9 @@ func (a *Agent) getFingerprint() string {
|
|||||||
|
|
||||||
// if no fingerprint is found, generate one
|
// if no fingerprint is found, generate one
|
||||||
fingerprint, err := host.HostID()
|
fingerprint, err := host.HostID()
|
||||||
if err != nil || fingerprint == "" {
|
// we ignore a commonly known "product_uuid" known not to be unique
|
||||||
fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel
|
if err != nil || fingerprint == "" || fingerprint == "03000200-0400-0500-0006-000700080009" {
|
||||||
|
fingerprint = a.systemDetails.Hostname + a.systemDetails.CpuModel
|
||||||
}
|
}
|
||||||
|
|
||||||
// hash fingerprint
|
// hash fingerprint
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func createTestCacheData() *system.CombinedData {
|
|||||||
DiskTotal: 100000,
|
DiskTotal: 100000,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
{
|
||||||
@@ -128,7 +128,7 @@ func TestCacheMultipleIntervals(t *testing.T) {
|
|||||||
Mem: 16384,
|
Mem: 16384,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "test-host-2",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestCacheOverwrite(t *testing.T) {
|
|||||||
Mem: 32768,
|
Mem: 32768,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Info: system.Info{
|
||||||
Hostname: "updated-host",
|
AgentVersion: "0.12.0",
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{},
|
Containers: []*container.Stats{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
@@ -201,7 +198,7 @@ func (client *WebSocketClient) handleAuthChallenge(msg *common.HubRequest[cbor.R
|
|||||||
|
|
||||||
if authRequest.NeedSysInfo {
|
if authRequest.NeedSysInfo {
|
||||||
response.Name, _ = GetEnv("SYSTEM_NAME")
|
response.Name, _ = GetEnv("SYSTEM_NAME")
|
||||||
response.Hostname = client.agent.systemInfo.Hostname
|
response.Hostname = client.agent.systemDetails.Hostname
|
||||||
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
serverAddr := client.agent.connectionManager.serverOptions.Addr
|
||||||
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
_, response.Port, _ = net.SplitHostPort(serverAddr)
|
||||||
}
|
}
|
||||||
@@ -259,41 +256,17 @@ func (client *WebSocketClient) sendMessage(data any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendResponse sends a response with optional request ID for the new protocol
|
// 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.
|
||||||
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
func (client *WebSocketClient) sendResponse(data any, requestID *uint32) error {
|
||||||
if requestID != nil {
|
if requestID != nil {
|
||||||
// New format with ID - use typed fields
|
response := newAgentResponse(data, requestID)
|
||||||
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 systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = 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)
|
return client.sendMessage(response)
|
||||||
} else {
|
}
|
||||||
// Legacy format - send data directly
|
// Legacy format - send data directly
|
||||||
return client.sendMessage(data)
|
return client.sendMessage(data)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// getUserAgent returns one of two User-Agent strings based on current time.
|
// getUserAgent returns one of two User-Agent strings based on current time.
|
||||||
// This is used to avoid being blocked by Cloudflare or other anti-bot measures.
|
// This is used to avoid being blocked by Cloudflare or other anti-bot measures.
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the appropriate root mount point for this system
|
||||||
|
rootMountPoint := a.getRootMountPoint()
|
||||||
|
|
||||||
// Use FILESYSTEM env var to find root filesystem
|
// Use FILESYSTEM env var to find root filesystem
|
||||||
if filesystem != "" {
|
if filesystem != "" {
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
@@ -138,7 +141,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
// fmt.Println(p.Device, p.Mountpoint)
|
// fmt.Println(p.Device, p.Mountpoint)
|
||||||
// Binary root fallback or docker root fallback
|
// Binary root fallback or docker root fallback
|
||||||
if !hasRoot && (p.Mountpoint == "/" || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
if !hasRoot && (p.Mountpoint == rootMountPoint || (p.Mountpoint == "/etc/hosts" && strings.HasPrefix(p.Device, "/dev"))) {
|
||||||
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
fs, match := findIoDevice(filepath.Base(p.Device), diskIoCounters, a.fsStats)
|
||||||
if match {
|
if match {
|
||||||
addFsStat(fs, p.Mountpoint, true)
|
addFsStat(fs, p.Mountpoint, true)
|
||||||
@@ -174,8 +177,8 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
// If no root filesystem set, use fallback
|
// If no root filesystem set, use fallback
|
||||||
if !hasRoot {
|
if !hasRoot {
|
||||||
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
rootDevice, _ := findIoDevice(filepath.Base(filesystem), diskIoCounters, a.fsStats)
|
||||||
slog.Info("Root disk", "mountpoint", "/", "io", rootDevice)
|
slog.Info("Root disk", "mountpoint", rootMountPoint, "io", rootDevice)
|
||||||
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: "/"}
|
a.fsStats[rootDevice] = &system.FsStats{Root: true, Mountpoint: rootMountPoint}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.initializeDiskIoStats(diskIoCounters)
|
a.initializeDiskIoStats(diskIoCounters)
|
||||||
@@ -222,8 +225,19 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
|
|
||||||
// Updates disk usage statistics for all monitored filesystems
|
// Updates disk usage statistics for all monitored filesystems
|
||||||
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
||||||
|
// Check if we should skip extra filesystem collection to avoid waking sleeping disks.
|
||||||
|
// Root filesystem is always updated since it can't be sleeping while the agent runs.
|
||||||
|
// Always collect on first call (lastDiskUsageUpdate is zero) or if caching is disabled.
|
||||||
|
cacheExtraFs := a.diskUsageCacheDuration > 0 &&
|
||||||
|
!a.lastDiskUsageUpdate.IsZero() &&
|
||||||
|
time.Since(a.lastDiskUsageUpdate) < a.diskUsageCacheDuration
|
||||||
|
|
||||||
// disk usage
|
// disk usage
|
||||||
for _, stats := range a.fsStats {
|
for _, stats := range a.fsStats {
|
||||||
|
// Skip non-root filesystems if caching is active
|
||||||
|
if cacheExtraFs && !stats.Root {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
if d, err := disk.Usage(stats.Mountpoint); err == nil {
|
||||||
stats.DiskTotal = bytesToGigabytes(d.Total)
|
stats.DiskTotal = bytesToGigabytes(d.Total)
|
||||||
stats.DiskUsed = bytesToGigabytes(d.Used)
|
stats.DiskUsed = bytesToGigabytes(d.Used)
|
||||||
@@ -241,6 +255,11 @@ func (a *Agent) updateDiskUsage(systemStats *system.Stats) {
|
|||||||
stats.TotalWrite = 0
|
stats.TotalWrite = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the last disk usage update time when we've collected extra filesystems
|
||||||
|
if !cacheExtraFs {
|
||||||
|
a.lastDiskUsageUpdate = time.Now()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates disk I/O statistics for all monitored filesystems
|
// Updates disk I/O statistics for all monitored filesystems
|
||||||
@@ -312,3 +331,32 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRootMountPoint returns the appropriate root mount point for the system
|
||||||
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
|
func (a *Agent) getRootMountPoint() string {
|
||||||
|
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||||
|
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
|
content := string(osReleaseContent)
|
||||||
|
if strings.Contains(content, "fedora") && strings.Contains(content, "silverblue") ||
|
||||||
|
strings.Contains(content, "coreos") ||
|
||||||
|
strings.Contains(content, "flatcar") ||
|
||||||
|
strings.Contains(content, "rhel-atomic") ||
|
||||||
|
strings.Contains(content, "centos-atomic") {
|
||||||
|
// Verify that /sysroot exists before returning it
|
||||||
|
if _, err := os.Stat("/sysroot"); err == nil {
|
||||||
|
return "/sysroot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if /run/ostree is present (ostree-based systems like Silverblue)
|
||||||
|
if _, err := os.Stat("/run/ostree"); err == nil {
|
||||||
|
// Verify that /sysroot exists before returning it
|
||||||
|
if _, err := os.Stat("/sysroot"); err == nil {
|
||||||
|
return "/sysroot"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
@@ -233,3 +234,86 @@ func TestExtraFsKeyGeneration(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageCaching(t *testing.T) {
|
||||||
|
t.Run("caching disabled updates all filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 0, // caching disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// Both should be updated (non-zero values from disk.Usage)
|
||||||
|
// Root stats should be populated in systemStats
|
||||||
|
assert.True(t, agent.lastDiskUsageUpdate.IsZero() || !agent.lastDiskUsageUpdate.IsZero(),
|
||||||
|
"lastDiskUsageUpdate should be set when caching is disabled")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("caching enabled always updates root filesystem", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/", DiskTotal: 100, DiskUsed: 50},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage", DiskTotal: 200, DiskUsed: 100},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Hour,
|
||||||
|
lastDiskUsageUpdate: time.Now(), // cache is fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original extra fs values
|
||||||
|
originalExtraTotal := agent.fsStats["sdb"].DiskTotal
|
||||||
|
originalExtraUsed := agent.fsStats["sdb"].DiskUsed
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// Root should be updated (systemStats populated from disk.Usage call)
|
||||||
|
// We can't easily check if disk.Usage was called, but we verify the flow works
|
||||||
|
|
||||||
|
// Extra filesystem should retain cached values (not reset)
|
||||||
|
assert.Equal(t, originalExtraTotal, agent.fsStats["sdb"].DiskTotal,
|
||||||
|
"extra filesystem DiskTotal should be unchanged when cached")
|
||||||
|
assert.Equal(t, originalExtraUsed, agent.fsStats["sdb"].DiskUsed,
|
||||||
|
"extra filesystem DiskUsed should be unchanged when cached")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("first call always updates all filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Hour,
|
||||||
|
// lastDiskUsageUpdate is zero (first call)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// After first call, lastDiskUsageUpdate should be set
|
||||||
|
assert.False(t, agent.lastDiskUsageUpdate.IsZero(),
|
||||||
|
"lastDiskUsageUpdate should be set after first call")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired cache updates extra filesystems", func(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
fsStats: map[string]*system.FsStats{
|
||||||
|
"sda": {Root: true, Mountpoint: "/"},
|
||||||
|
"sdb": {Root: false, Mountpoint: "/mnt/storage"},
|
||||||
|
},
|
||||||
|
diskUsageCacheDuration: 1 * time.Millisecond,
|
||||||
|
lastDiskUsageUpdate: time.Now().Add(-1 * time.Second), // cache expired
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats system.Stats
|
||||||
|
agent.updateDiskUsage(&stats)
|
||||||
|
|
||||||
|
// lastDiskUsageUpdate should be refreshed since cache expired
|
||||||
|
assert.True(t, time.Since(agent.lastDiskUsageUpdate) < time.Second,
|
||||||
|
"lastDiskUsageUpdate should be refreshed when cache expires")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,6 +25,10 @@ import (
|
|||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.)
|
||||||
|
// This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K
|
||||||
|
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Docker API timeout in milliseconds
|
// Docker API timeout in milliseconds
|
||||||
dockerTimeoutMs = 2100
|
dockerTimeoutMs = 2100
|
||||||
@@ -55,6 +60,7 @@ type dockerManager struct {
|
|||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -473,7 +479,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager(a *Agent) *dockerManager {
|
func newDockerManager() *dockerManager {
|
||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -559,7 +565,7 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
|
|
||||||
// If using podman, return client
|
// If using podman, return client
|
||||||
if strings.Contains(dockerHost, "podman") {
|
if strings.Contains(dockerHost, "podman") {
|
||||||
a.systemInfo.Podman = true
|
manager.usingPodman = true
|
||||||
manager.goodDockerVersion = true
|
manager.goodDockerVersion = true
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
@@ -688,17 +694,26 @@ func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
if err := decodeDockerLogStream(resp.Body, &builder); err != nil {
|
multiplexed := resp.Header.Get("Content-Type") == "application/vnd.docker.multiplexed-stream"
|
||||||
|
if err := decodeDockerLogStream(resp.Body, &builder, multiplexed); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.String(), nil
|
// Strip ANSI escape sequences from logs for clean display in web UI
|
||||||
|
logs := builder.String()
|
||||||
|
if strings.Contains(logs, "\x1b") {
|
||||||
|
logs = ansiEscapePattern.ReplaceAllString(logs, "")
|
||||||
|
}
|
||||||
|
return logs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
func decodeDockerLogStream(reader io.Reader, builder *strings.Builder, multiplexed bool) error {
|
||||||
|
if !multiplexed {
|
||||||
|
_, err := io.Copy(builder, io.LimitReader(reader, maxTotalLogSize))
|
||||||
|
return err
|
||||||
|
}
|
||||||
const headerSize = 8
|
const headerSize = 8
|
||||||
var header [headerSize]byte
|
var header [headerSize]byte
|
||||||
buf := make([]byte, 0, dockerLogsTail*200)
|
|
||||||
totalBytesRead := 0
|
totalBytesRead := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -722,36 +737,37 @@ func decodeDockerLogStream(reader io.Reader, builder *strings.Builder) error {
|
|||||||
// Check if reading this frame would exceed total log size limit
|
// Check if reading this frame would exceed total log size limit
|
||||||
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
if totalBytesRead+int(frameLen) > maxTotalLogSize {
|
||||||
// Read and discard remaining data to avoid blocking
|
// Read and discard remaining data to avoid blocking
|
||||||
_, _ = io.Copy(io.Discard, io.LimitReader(reader, int64(frameLen)))
|
_, _ = io.CopyN(io.Discard, reader, int64(frameLen))
|
||||||
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
slog.Debug("Truncating logs: limit reached", "read", totalBytesRead, "limit", maxTotalLogSize)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
buf = allocateBuffer(buf, int(frameLen))
|
n, err := io.CopyN(builder, reader, int64(frameLen))
|
||||||
if _, err := io.ReadFull(reader, buf[:frameLen]); err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
if len(buf) > 0 {
|
|
||||||
builder.Write(buf[:min(int(frameLen), len(buf))])
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
builder.Write(buf[:frameLen])
|
totalBytesRead += int(n)
|
||||||
totalBytesRead += int(frameLen)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allocateBuffer(current []byte, needed int) []byte {
|
// GetHostInfo fetches the system info from Docker
|
||||||
if cap(current) >= needed {
|
func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
||||||
return current[:needed]
|
resp, err := dm.client.Get("http://localhost/info")
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
}
|
}
|
||||||
return make([]byte, needed)
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||||
|
return info, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
return info, nil
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
}
|
||||||
return b
|
|
||||||
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
|
return dm.usingPodman
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -802,6 +802,24 @@ func TestNetworkRateCalculationFormula(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetHostInfo(t *testing.T) {
|
||||||
|
data, err := os.ReadFile("test-data/system_info.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var info container.HostInfo
|
||||||
|
err = json.Unmarshal(data, &info)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "6.8.0-31-generic", info.KernelVersion)
|
||||||
|
assert.Equal(t, "Ubuntu 24.04 LTS", info.OperatingSystem)
|
||||||
|
// assert.Equal(t, "24.04", info.OSVersion)
|
||||||
|
// assert.Equal(t, "linux", info.OSType)
|
||||||
|
// assert.Equal(t, "x86_64", info.Architecture)
|
||||||
|
assert.EqualValues(t, 4, info.NCPU)
|
||||||
|
assert.EqualValues(t, 2095882240, info.MemTotal)
|
||||||
|
// assert.Equal(t, "27.0.1", info.ServerVersion)
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
func TestDeltaTrackerCacheTimeIsolation(t *testing.T) {
|
||||||
// Test that different cache times have separate DeltaTracker instances
|
// Test that different cache times have separate DeltaTracker instances
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
@@ -932,6 +950,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
input []byte
|
input []byte
|
||||||
expected string
|
expected string
|
||||||
expectError bool
|
expectError bool
|
||||||
|
multiplexed bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "simple log entry",
|
name: "simple log entry",
|
||||||
@@ -942,6 +961,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "Hello World",
|
expected: "Hello World",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple frames",
|
name: "multiple frames",
|
||||||
@@ -955,6 +975,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "HelloWorld",
|
expected: "HelloWorld",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero length frame",
|
name: "zero length frame",
|
||||||
@@ -967,12 +988,20 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: "Hello",
|
expected: "Hello",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty input",
|
name: "empty input",
|
||||||
input: []byte{},
|
input: []byte{},
|
||||||
expected: "",
|
expected: "",
|
||||||
expectError: false,
|
expectError: false,
|
||||||
|
multiplexed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "raw stream (not multiplexed)",
|
||||||
|
input: []byte("raw log content"),
|
||||||
|
expected: "raw log content",
|
||||||
|
multiplexed: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1009,7 @@ func TestDecodeDockerLogStream(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
reader := bytes.NewReader(tt.input)
|
reader := bytes.NewReader(tt.input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, tt.multiplexed)
|
||||||
|
|
||||||
if tt.expectError {
|
if tt.expectError {
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
@@ -1004,7 +1033,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|||||||
|
|
||||||
reader := bytes.NewReader(input)
|
reader := bytes.NewReader(input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, true)
|
||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "log frame size")
|
assert.Contains(t, err.Error(), "log frame size")
|
||||||
@@ -1038,7 +1067,7 @@ func TestDecodeDockerLogStreamMemoryProtection(t *testing.T) {
|
|||||||
|
|
||||||
reader := bytes.NewReader(input)
|
reader := bytes.NewReader(input)
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
err := decodeDockerLogStream(reader, &builder)
|
err := decodeDockerLogStream(reader, &builder, true)
|
||||||
|
|
||||||
// Should complete without error (graceful truncation)
|
// Should complete without error (graceful truncation)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -1053,53 +1082,6 @@ 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) {
|
func TestShouldExcludeContainer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -1203,3 +1185,59 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
25
agent/gpu.go
25
agent/gpu.go
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -44,6 +44,7 @@ type GPUManager struct {
|
|||||||
rocmSmi bool
|
rocmSmi bool
|
||||||
tegrastats bool
|
tegrastats bool
|
||||||
intelGpuStats bool
|
intelGpuStats bool
|
||||||
|
nvml bool
|
||||||
GpuDataMap map[string]*system.GPUData
|
GpuDataMap map[string]*system.GPUData
|
||||||
// lastAvgData stores the last calculated averages for each GPU
|
// lastAvgData stores the last calculated averages for each GPU
|
||||||
// Used when a collection happens before new data arrives (Count == 0)
|
// Used when a collection happens before new data arrives (Count == 0)
|
||||||
@@ -297,8 +298,13 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
currentCount := uint32(gpu.Count)
|
currentCount := uint32(gpu.Count)
|
||||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||||
|
|
||||||
// If no new data arrived, use last known average
|
// If no new data arrived
|
||||||
if deltaCount == 0 {
|
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
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +402,7 @@ func (gm *GPUManager) detectGPUs() error {
|
|||||||
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
if _, err := exec.LookPath(intelGpuStatsCmd); err == nil {
|
||||||
gm.intelGpuStats = true
|
gm.intelGpuStats = true
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats {
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats || gm.intelGpuStats || gm.nvml {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
return fmt.Errorf("no GPU found - install nvidia-smi, rocm-smi, tegrastats, or intel_gpu_top")
|
||||||
@@ -467,8 +473,21 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
if gm.nvidiaSmi {
|
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)
|
gm.startCollector(nvidiaSmiCmd)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
gm.startCollector(nvidiaSmiCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
if gm.rocmSmi {
|
if gm.rocmSmi {
|
||||||
gm.startCollector(rocmSmiCmd)
|
gm.startCollector(rocmSmiCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
224
agent/gpu_nvml.go
Normal file
224
agent/gpu_nvml.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
//go:build (linux || windows) && (amd64 || arm64)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
agent/gpu_nvml_linux.go
Normal file
57
agent/gpu_nvml_linux.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//go:build linux && (amd64 || arm64)
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ebitengine/purego"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
33
agent/gpu_nvml_unsupported.go
Normal file
33
agent/gpu_nvml_unsupported.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//go:build (!linux && !windows) || (!amd64 && !arm64)
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type nvmlCollector struct {
|
||||||
|
gm *GPUManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nvmlCollector) init() error {
|
||||||
|
return fmt.Errorf("nvml not supported on this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *nvmlCollector) start() {}
|
||||||
|
|
||||||
|
func (c *nvmlCollector) collect() {}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
25
agent/gpu_nvml_windows.go
Normal file
25
agent/gpu_nvml_windows.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build windows && (amd64 || arm64)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -825,7 +825,7 @@ func TestInitializeSnapshots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateGPUAverage(t *testing.T) {
|
func TestCalculateGPUAverage(t *testing.T) {
|
||||||
t.Run("returns zero value when deltaCount is zero", func(t *testing.T) {
|
t.Run("returns cached average when deltaCount is zero", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
5000: {
|
5000: {
|
||||||
@@ -841,6 +841,7 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
Count: 10.0, // Same as snapshot, so delta = 0
|
Count: 10.0, // Same as snapshot, so delta = 0
|
||||||
Usage: 100.0,
|
Usage: 100.0,
|
||||||
Power: 200.0,
|
Power: 200.0,
|
||||||
|
Temperature: 50.0, // Non-zero to avoid "suspended" check
|
||||||
}
|
}
|
||||||
|
|
||||||
result := gm.calculateGPUAverage("0", gpu, 5000)
|
result := gm.calculateGPUAverage("0", gpu, 5000)
|
||||||
@@ -849,6 +850,31 @@ func TestCalculateGPUAverage(t *testing.T) {
|
|||||||
assert.Equal(t, 100.0, result.Power, "Should return cached average")
|
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) {
|
t.Run("calculates average for standard GPU", func(t *testing.T) {
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
lastSnapshots: map[uint16]map[string]*gpuSnapshot{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlerContext provides context for request handlers
|
// HandlerContext provides context for request handlers
|
||||||
@@ -94,7 +94,7 @@ func (h *GetDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
var options common.DataRequestOptions
|
var options common.DataRequestOptions
|
||||||
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
_ = cbor.Unmarshal(hctx.Request.Data, &options)
|
||||||
|
|
||||||
sysStats := hctx.Agent.gatherStats(options.CacheTimeMs)
|
sysStats := hctx.Agent.gatherStats(options)
|
||||||
return hctx.SendResponse(sysStats, hctx.RequestID)
|
return hctx.SendResponse(sysStats, hctx.RequestID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
agent/response.go
Normal file
31
agent/response.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -13,9 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
@@ -165,20 +163,9 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// responder that writes AgentResponse to stdout
|
// responder that writes AgentResponse to stdout
|
||||||
|
// Uses legacy typed fields for backward compatibility with <= 0.17
|
||||||
sshResponder := func(data any, requestID *uint32) error {
|
sshResponder := func(data any, requestID *uint32) error {
|
||||||
response := common.AgentResponse{Id: requestID}
|
response := newAgentResponse(data, requestID)
|
||||||
switch v := data.(type) {
|
|
||||||
case *system.CombinedData:
|
|
||||||
response.SystemData = v
|
|
||||||
case string:
|
|
||||||
response.String = &v
|
|
||||||
case map[string]smart.SmartData:
|
|
||||||
response.SmartData = v
|
|
||||||
case systemd.ServiceDetails:
|
|
||||||
response.ServiceInfo = v
|
|
||||||
default:
|
|
||||||
response.Error = fmt.Sprintf("unsupported response type: %T", data)
|
|
||||||
}
|
|
||||||
return cbor.NewEncoder(w).Encode(response)
|
return cbor.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +189,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(60_000)
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
err = json.Unmarshal([]byte(encodedData), &decodedJson)
|
||||||
assert.Error(t, err, "Should not be valid JSON data")
|
assert.Error(t, err, "Should not be valid JSON data")
|
||||||
|
|
||||||
assert.Equal(t, testData.Info.Hostname, decodedCbor.Info.Hostname)
|
assert.Equal(t, testData.Details.Hostname, decodedCbor.Details.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedCbor.Stats.Cpu)
|
||||||
} else {
|
} else {
|
||||||
// Should be JSON - try to decode as JSON
|
// Should be JSON - try to decode as JSON
|
||||||
@@ -526,7 +526,7 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
|||||||
assert.Error(t, err, "Should not be valid CBOR data")
|
assert.Error(t, err, "Should not be valid CBOR data")
|
||||||
|
|
||||||
// Verify the decoded JSON data matches our test data
|
// Verify the decoded JSON data matches our test data
|
||||||
assert.Equal(t, testData.Info.Hostname, decodedJson.Info.Hostname)
|
assert.Equal(t, testData.Details.Hostname, decodedJson.Details.Hostname)
|
||||||
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
assert.Equal(t, testData.Stats.Cpu, decodedJson.Stats.Cpu)
|
||||||
|
|
||||||
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
// Verify it looks like JSON (starts with '{' and contains readable field names)
|
||||||
@@ -550,13 +550,12 @@ func createTestCombinedData() *system.CombinedData {
|
|||||||
DiskUsed: 549755813888, // 512GB
|
DiskUsed: 549755813888, // 512GB
|
||||||
DiskPct: 50.0,
|
DiskPct: 50.0,
|
||||||
},
|
},
|
||||||
Info: system.Info{
|
Details: &system.Details{
|
||||||
Hostname: "test-host",
|
Hostname: "test-host",
|
||||||
Cores: 8,
|
},
|
||||||
CpuModel: "Test CPU Model",
|
Info: system.Info{
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
AgentVersion: "0.12.0",
|
AgentVersion: "0.12.0",
|
||||||
Os: system.Linux,
|
|
||||||
},
|
},
|
||||||
Containers: []*container.Stats{
|
Containers: []*container.Stats{
|
||||||
{
|
{
|
||||||
|
|||||||
108
agent/smart.go
108
agent/smart.go
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
|
||||||
"golang.org/x/exp/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
@@ -29,6 +30,7 @@ type SmartManager struct {
|
|||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
binPath string
|
binPath string
|
||||||
|
excludedDevices map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -185,6 +187,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||||
|
finalDevices = sm.filterExcludedDevices(finalDevices)
|
||||||
sm.updateSmartDevices(finalDevices)
|
sm.updateSmartDevices(finalDevices)
|
||||||
|
|
||||||
if len(finalDevices) == 0 {
|
if len(finalDevices) == 0 {
|
||||||
@@ -232,6 +235,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
return devices, nil
|
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
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
// when the reported device type is ambiguous or missing.
|
// when the reported device type is ambiguous or missing.
|
||||||
@@ -378,12 +422,16 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
// for initial data collection when no cached data exists
|
// for initial data collection when no cached data exists
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
// Check if we have any existing data for this device
|
||||||
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
hasExistingData := sm.hasDataForDevice(deviceInfo.Name)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try with -n standby first if we have existing data
|
// Try with -n standby first if we have existing data
|
||||||
@@ -398,7 +446,7 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// No cached data, need to collect initial data by bypassing standby
|
// No cached data, need to collect initial data by bypassing standby
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel2()
|
defer cancel2()
|
||||||
args = sm.smartctlArgs(deviceInfo, false)
|
args = sm.smartctlArgs(deviceInfo, false)
|
||||||
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
cmd = exec.CommandContext(ctx2, sm.binPath, args...)
|
||||||
@@ -407,12 +455,40 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
|
|
||||||
hasValidData := sm.parseSmartOutput(deviceInfo, output)
|
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 !hasValidData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
|
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
|
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,11 +986,33 @@ func (sm *SmartManager) detectSmartctl() (string, error) {
|
|||||||
return "", errors.New("smartctl not found")
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// NewSmartManager creates and initializes a new SmartManager
|
// NewSmartManager creates and initializes a new SmartManager
|
||||||
func NewSmartManager() (*SmartManager, error) {
|
func NewSmartManager() (*SmartManager, error) {
|
||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
|
sm.refreshExcludedDevices()
|
||||||
path, err := sm.detectSmartctl()
|
path, err := sm.detectSmartctl()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug(err.Error())
|
slog.Debug(err.Error())
|
||||||
|
|||||||
@@ -588,3 +588,228 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
113
agent/system.go
113
agent/system.go
@@ -2,15 +2,18 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
"github.com/shirou/gopsutil/v4/cpu"
|
||||||
@@ -27,41 +30,79 @@ type prevDisk struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) initializeSystemInfo() {
|
func (a *Agent) refreshSystemDetails() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
a.systemInfo.Hostname, _ = os.Hostname()
|
|
||||||
|
// get host info from Docker if available
|
||||||
|
var hostInfo container.HostInfo
|
||||||
|
|
||||||
|
if a.dockerManager != nil {
|
||||||
|
a.systemDetails.Podman = a.dockerManager.IsPodman()
|
||||||
|
hostInfo, _ = a.dockerManager.GetHostInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
a.systemDetails.Hostname, _ = os.Hostname()
|
||||||
|
if arch, err := host.KernelArch(); err == nil {
|
||||||
|
a.systemDetails.Arch = arch
|
||||||
|
} else {
|
||||||
|
a.systemDetails.Arch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
platform, _, version, _ := host.PlatformInformation()
|
platform, _, version, _ := host.PlatformInformation()
|
||||||
|
|
||||||
if platform == "darwin" {
|
if platform == "darwin" {
|
||||||
a.systemInfo.KernelVersion = version
|
a.systemDetails.Os = system.Darwin
|
||||||
a.systemInfo.Os = system.Darwin
|
a.systemDetails.OsName = fmt.Sprintf("macOS %s", version)
|
||||||
} else if strings.Contains(platform, "indows") {
|
} else if strings.Contains(platform, "indows") {
|
||||||
a.systemInfo.KernelVersion = fmt.Sprintf("%s %s", strings.Replace(platform, "Microsoft ", "", 1), version)
|
a.systemDetails.Os = system.Windows
|
||||||
a.systemInfo.Os = system.Windows
|
a.systemDetails.OsName = strings.Replace(platform, "Microsoft ", "", 1)
|
||||||
|
a.systemDetails.Kernel = version
|
||||||
} else if platform == "freebsd" {
|
} else if platform == "freebsd" {
|
||||||
a.systemInfo.Os = system.Freebsd
|
a.systemDetails.Os = system.Freebsd
|
||||||
a.systemInfo.KernelVersion = version
|
a.systemDetails.Kernel, _ = host.KernelVersion()
|
||||||
|
if prettyName, err := getOsPrettyName(); err == nil {
|
||||||
|
a.systemDetails.OsName = prettyName
|
||||||
} else {
|
} else {
|
||||||
a.systemInfo.Os = system.Linux
|
a.systemDetails.OsName = "FreeBSD"
|
||||||
|
}
|
||||||
|
} 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.systemInfo.KernelVersion == "" {
|
|
||||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpu model
|
// cpu model
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
a.systemInfo.CpuModel = info[0].ModelName
|
a.systemDetails.CpuModel = info[0].ModelName
|
||||||
}
|
}
|
||||||
// cores / threads
|
// cores / threads
|
||||||
a.systemInfo.Cores, _ = cpu.Counts(false)
|
cores, _ := cpu.Counts(false)
|
||||||
if threads, err := cpu.Counts(true); err == nil {
|
threads := hostInfo.NCPU
|
||||||
if threads > 0 && threads < a.systemInfo.Cores {
|
if threads == 0 {
|
||||||
// in lxc logical cores reflects container limits, so use that as cores if lower
|
threads, _ = cpu.Counts(true)
|
||||||
a.systemInfo.Cores = threads
|
}
|
||||||
} else {
|
// in lxc, logical cores reflects container limits, so use that as cores if lower
|
||||||
a.systemInfo.Threads = threads
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,21 +236,16 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// update base system info
|
// update system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
a.systemInfo.LoadAvg = systemStats.LoadAvg
|
||||||
// TODO: remove these in future release in favor of load avg array
|
|
||||||
a.systemInfo.LoadAvg1 = systemStats.LoadAvg[0]
|
|
||||||
a.systemInfo.LoadAvg5 = systemStats.LoadAvg[1]
|
|
||||||
a.systemInfo.LoadAvg15 = systemStats.LoadAvg[2]
|
|
||||||
a.systemInfo.MemPct = systemStats.MemPct
|
a.systemInfo.MemPct = systemStats.MemPct
|
||||||
a.systemInfo.DiskPct = systemStats.DiskPct
|
a.systemInfo.DiskPct = systemStats.DiskPct
|
||||||
|
a.systemInfo.Battery = systemStats.Battery
|
||||||
a.systemInfo.Uptime, _ = host.Uptime()
|
a.systemInfo.Uptime, _ = host.Uptime()
|
||||||
// TODO: in future release, remove MB bandwidth values in favor of bytes
|
|
||||||
a.systemInfo.Bandwidth = twoDecimals(systemStats.NetworkSent + systemStats.NetworkRecv)
|
|
||||||
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
|
||||||
slog.Debug("sysinfo", "data", a.systemInfo)
|
a.systemInfo.Threads = a.systemDetails.Threads
|
||||||
|
|
||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
@@ -239,3 +275,24 @@ func getARCSize() (uint64, error) {
|
|||||||
|
|
||||||
return 0, fmt.Errorf("failed to parse size field")
|
return 0, fmt.Errorf("failed to parse size field")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getOsPrettyName attempts to get the pretty OS name from /etc/os-release on Linux systems
|
||||||
|
func getOsPrettyName() (string, error) {
|
||||||
|
file, err := os.Open("/etc/os-release")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if after, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
|
||||||
|
value := after
|
||||||
|
value = strings.Trim(value, `"`)
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.New("pretty name not found")
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -17,9 +18,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var errNoActiveTime = errors.New("no active time")
|
||||||
errNoActiveTime = errors.New("no active time")
|
|
||||||
)
|
|
||||||
|
|
||||||
// systemdManager manages the collection of systemd service statistics.
|
// systemdManager manages the collection of systemd service statistics.
|
||||||
type systemdManager struct {
|
type systemdManager struct {
|
||||||
@@ -27,18 +26,48 @@ type systemdManager struct {
|
|||||||
serviceStatsMap map[string]*systemd.Service
|
serviceStatsMap map[string]*systemd.Service
|
||||||
isRunning bool
|
isRunning bool
|
||||||
hasFreshStats bool
|
hasFreshStats bool
|
||||||
|
patterns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSystemdAvailable checks if systemd is used on the system to avoid unnecessary connection attempts (#1548)
|
||||||
|
func isSystemdAvailable() bool {
|
||||||
|
paths := []string{
|
||||||
|
"/run/systemd/system",
|
||||||
|
"/run/dbus/system_bus_socket",
|
||||||
|
"/var/run/dbus/system_bus_socket",
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); 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.
|
// newSystemdManager creates a new systemdManager.
|
||||||
func newSystemdManager() (*systemdManager, error) {
|
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")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
slog.Debug("Error connecting to systemd", "err", err, "ref", "https://beszel.dev/guide/systemd")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := &systemdManager{
|
manager := &systemdManager{
|
||||||
serviceStatsMap: make(map[string]*systemd.Service),
|
serviceStatsMap: make(map[string]*systemd.Service),
|
||||||
|
patterns: getServicePatterns(),
|
||||||
}
|
}
|
||||||
|
|
||||||
manager.startWorker(conn)
|
manager.startWorker(conn)
|
||||||
@@ -62,6 +91,24 @@ func (sm *systemdManager) startWorker(conn *dbus.Conn) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// getServiceStats collects statistics for all running systemd services.
|
||||||
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*systemd.Service {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
@@ -91,7 +138,7 @@ func (sm *systemdManager) getServiceStats(conn *dbus.Conn, refresh bool) []*syst
|
|||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, []string{"*.service"})
|
units, err := conn.ListUnitsByPatternsContext(context.Background(), []string{"loaded"}, sm.patterns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error listing systemd service units", "err", err)
|
slog.Error("Error listing systemd service units", "err", err)
|
||||||
return nil
|
return nil
|
||||||
@@ -227,3 +274,26 @@ func unescapeServiceName(name string) string {
|
|||||||
}
|
}
|
||||||
return unescaped
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ func (sm *systemdManager) getServiceStats(conn any, refresh bool) []*systemd.Ser
|
|||||||
return nil
|
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) {
|
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
||||||
return nil, errors.New("systemd manager unavailable")
|
return nil, errors.New("systemd manager unavailable")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -46,3 +48,141 @@ func TestUnescapeServiceNameInvalid(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
17
agent/test-data/system_info.json
Normal file
17
agent/test-data/system_info.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.16.0-beta.1"
|
Version = "0.18.1"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
42
go.mod
42
go.mod
@@ -1,25 +1,27 @@
|
|||||||
module github.com/henrygd/beszel
|
module github.com/henrygd/beszel
|
||||||
|
|
||||||
go 1.25.3
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.6.0
|
github.com/coreos/go-systemd/v22 v22.6.0
|
||||||
github.com/distatus/battery v0.11.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/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.8.9
|
github.com/lxzan/gws v1.8.9
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1
|
github.com/nicholas-fedor/shoutrrr v0.13.1
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pocketbase/pocketbase v0.31.0
|
github.com/pocketbase/pocketbase v0.35.1
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10
|
github.com/shirou/gopsutil/v4 v4.25.12
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.1
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||||
|
golang.org/x/sys v0.40.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,17 +33,16 @@ require (
|
|||||||
github.com/dolthub/maphash v0.1.0 // indirect
|
github.com/dolthub/maphash v0.1.0 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.9.0 // indirect
|
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -49,20 +50,19 @@ require (
|
|||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/image v0.32.0 // indirect
|
golang.org/x/image v0.34.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/oauth2 v0.32.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/term v0.39.0 // indirect
|
||||||
golang.org/x/term v0.36.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
modernc.org/libc v1.66.10 // indirect
|
modernc.org/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.39.1 // indirect
|
modernc.org/sqlite v1.43.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
118
go.sum
118
go.sum
@@ -25,16 +25,16 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
|
|||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
@@ -51,24 +51,26 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
|||||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
|
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
|
||||||
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -83,19 +85,19 @@ 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1 h1:DND1gW8UM8GYG8c0bUZ5fPFAnm3id8noPdfaFBUmezk=
|
github.com/nicholas-fedor/shoutrrr v0.13.1 h1:llEoHNbnMM4GfQ9+2Ns3n6ssvNfi3NPWluM0AQiicoY=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.11.1/go.mod h1:RZuSZSEaSimS47zTOLXb6HJDwLjDHiuJ9SrzxsDcWaQ=
|
github.com/nicholas-fedor/shoutrrr v0.13.1/go.mod h1:kU4cFJpEAtTzl3iV0l+XUXmM90OlC5T01b7roM4/pYM=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/pocketbase v0.31.0 h1:JaOtSDytdA+a0r4689Mrjda4rmq+BaHgEJkPeOIydms=
|
github.com/pocketbase/pocketbase v0.35.1 h1:Cd5ivUThTw29myY/tYa2cb0elkScBMseG6fExZsIQB8=
|
||||||
github.com/pocketbase/pocketbase v0.31.0/go.mod h1:p4a83n+DlBcTvvqhC7QDy0KDmQ2la2c6dgxdIBWwKiE=
|
github.com/pocketbase/pocketbase v0.35.1/go.mod h1:yQnh1o1Aq6wVuqcmZbRbDmIhc31AME/F5pnPR0Bdtmg=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -103,12 +105,12 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
|||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10 h1:at8lk/5T1OgtuCp+AwrDofFRjnvosn0nkN2OLQ6g8tA=
|
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.10/go.mod h1:+kSwyC8DRUD9XXEHCAFjK+0nuArFJM0lva+StQAcskM=
|
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
@@ -116,10 +118,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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
@@ -127,62 +129,66 @@ 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||||
|
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -191,8 +197,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
|
||||||
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type AlertManager struct {
|
|||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
UserID string
|
UserID string
|
||||||
|
SystemID string
|
||||||
Title string
|
Title string
|
||||||
Message string
|
Message string
|
||||||
Link string
|
Link string
|
||||||
@@ -48,6 +49,7 @@ type SystemAlertStats struct {
|
|||||||
GPU map[string]SystemAlertGPUData `json:"g"`
|
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
LoadAvg [3]float64 `json:"la"`
|
||||||
|
Battery [2]uint8 `json:"bat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertGPUData struct {
|
type SystemAlertGPUData struct {
|
||||||
@@ -77,7 +79,6 @@ var supportsTitle = map[string]struct{}{
|
|||||||
"ifttt": {},
|
"ifttt": {},
|
||||||
"join": {},
|
"join": {},
|
||||||
"lark": {},
|
"lark": {},
|
||||||
"matrix": {},
|
|
||||||
"ntfy": {},
|
"ntfy": {},
|
||||||
"opsgenie": {},
|
"opsgenie": {},
|
||||||
"pushbullet": {},
|
"pushbullet": {},
|
||||||
@@ -104,10 +105,84 @@ func NewAlertManager(app hubLike) *AlertManager {
|
|||||||
func (am *AlertManager) bindEvents() {
|
func (am *AlertManager) bindEvents() {
|
||||||
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
|
||||||
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
|
||||||
|
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotificationSilenced checks if a notification should be silenced based on configured quiet hours
|
||||||
|
func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
||||||
|
// Query for quiet hours windows that match this user and system
|
||||||
|
// Include both global windows (system is null/empty) and system-specific windows
|
||||||
|
var filter string
|
||||||
|
var params dbx.Params
|
||||||
|
|
||||||
|
if systemID == "" {
|
||||||
|
// If no systemID provided, only check global windows
|
||||||
|
filter = "user={:user} AND system=''"
|
||||||
|
params = dbx.Params{"user": userID}
|
||||||
|
} else {
|
||||||
|
// Check both global and system-specific windows
|
||||||
|
filter = "user={:user} AND (system='' OR system={:system})"
|
||||||
|
params = dbx.Params{
|
||||||
|
"user": userID,
|
||||||
|
"system": systemID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
quietHourWindows, err := am.hub.FindAllRecords("quiet_hours", dbx.NewExp(filter, params))
|
||||||
|
if err != nil || len(quietHourWindows) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for _, window := range quietHourWindows {
|
||||||
|
windowType := window.GetString("type")
|
||||||
|
start := window.GetDateTime("start").Time()
|
||||||
|
end := window.GetDateTime("end").Time()
|
||||||
|
|
||||||
|
if windowType == "daily" {
|
||||||
|
// For daily recurring windows, extract just the time portion and compare
|
||||||
|
// The start/end are stored as full datetime but we only care about HH:MM
|
||||||
|
startHour, startMin, _ := start.Clock()
|
||||||
|
endHour, endMin, _ := end.Clock()
|
||||||
|
nowHour, nowMin, _ := now.Clock()
|
||||||
|
|
||||||
|
// Convert to minutes since midnight for easier comparison
|
||||||
|
startMinutes := startHour*60 + startMin
|
||||||
|
endMinutes := endHour*60 + endMin
|
||||||
|
nowMinutes := nowHour*60 + nowMin
|
||||||
|
|
||||||
|
// Handle case where window crosses midnight
|
||||||
|
if endMinutes < startMinutes {
|
||||||
|
// Window crosses midnight (e.g., 23:00 - 01:00)
|
||||||
|
if nowMinutes >= startMinutes || nowMinutes < endMinutes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case (e.g., 09:00 - 17:00)
|
||||||
|
if nowMinutes >= startMinutes && nowMinutes < endMinutes {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// One-time window: check if current time is within the date range
|
||||||
|
if (now.After(start) || now.Equal(start)) && now.Before(end) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
|
// Check if alert is silenced
|
||||||
|
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
||||||
|
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.hub.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
|
|||||||
387
internal/alerts/alerts_battery_test.go
Normal file
387
internal/alerts/alerts_battery_test.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBatteryAlertLogic tests that battery alerts trigger when value drops BELOW threshold
|
||||||
|
// (opposite of other alerts like CPU, Memory, etc. which trigger when exceeding threshold)
|
||||||
|
func TestBatteryAlertLogic(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
require.NoError(t, err)
|
||||||
|
systemRecord := systems[0]
|
||||||
|
|
||||||
|
// Create a battery alert with threshold of 20% and min of 1 minute (immediate trigger)
|
||||||
|
batteryAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Battery",
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 20, // threshold: 20%
|
||||||
|
"min": 1, // 1 minute (immediate trigger for testing)
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify alert is not triggered initially
|
||||||
|
assert.False(t, batteryAlert.GetBool("triggered"), "Alert should not be triggered initially")
|
||||||
|
|
||||||
|
// Create system stats with battery at 50% (above threshold - should NOT trigger)
|
||||||
|
statsHigh := system.Stats{
|
||||||
|
Cpu: 10,
|
||||||
|
MemPct: 30,
|
||||||
|
DiskPct: 40,
|
||||||
|
Battery: [2]uint8{50, 1}, // 50% battery, discharging
|
||||||
|
}
|
||||||
|
statsHighJSON, _ := json.Marshal(statsHigh)
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": string(statsHighJSON),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create CombinedData for the alert handler
|
||||||
|
combinedDataHigh := &system.CombinedData{
|
||||||
|
Stats: statsHigh,
|
||||||
|
Info: system.Info{
|
||||||
|
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")
|
||||||
|
}
|
||||||
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
426
internal/alerts/alerts_quiet_hours_test.go
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertSilencedOneTime(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Create an alert
|
||||||
|
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a one-time quiet hours window (current time - 1 hour to current time + 1 hour)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Test that alert is silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced during active one-time window")
|
||||||
|
|
||||||
|
// Create a window that has already ended
|
||||||
|
pastStart := now.Add(-3 * time.Hour)
|
||||||
|
pastEnd := now.Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": pastStart,
|
||||||
|
"end": pastEnd,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Should still be silenced because of the first window
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should still be silenced (past window doesn't affect active window)")
|
||||||
|
|
||||||
|
// Clear all windows and create a future window
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
futureStart := now.Add(2 * time.Hour)
|
||||||
|
futureEnd := now.Add(3 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": futureStart,
|
||||||
|
"end": futureEnd,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should NOT be silenced (window hasn't started yet)
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced (window hasn't started)")
|
||||||
|
|
||||||
|
_ = alert
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedDaily(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Get current hour and create a window that includes current time
|
||||||
|
now := time.Now().UTC()
|
||||||
|
currentHour := now.Hour()
|
||||||
|
currentMin := now.Minute()
|
||||||
|
|
||||||
|
// Create a window from 1 hour ago to 1 hour from now
|
||||||
|
startHour := (currentHour - 1 + 24) % 24
|
||||||
|
endHour := (currentHour + 1) % 24
|
||||||
|
|
||||||
|
// Create times with just the hours/minutes we want (date doesn't matter for daily)
|
||||||
|
startTime := time.Date(2000, 1, 1, startHour, currentMin, 0, 0, time.UTC)
|
||||||
|
endTime := time.Date(2000, 1, 1, endHour, currentMin, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should be silenced (current time is within the daily window)
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced during active daily window")
|
||||||
|
|
||||||
|
// Clear windows and create one that doesn't include current time
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a window from 6-12 hours from now
|
||||||
|
futureStartHour := (currentHour + 6) % 24
|
||||||
|
futureEndHour := (currentHour + 12) % 24
|
||||||
|
|
||||||
|
startTime = time.Date(2000, 1, 1, futureStartHour, 0, 0, 0, time.UTC)
|
||||||
|
endTime = time.Date(2000, 1, 1, futureEndHour, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Alert should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced (outside daily window)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedDailyMidnightCrossing(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a window that crosses midnight: 22:00 - 02:00
|
||||||
|
startTime := time.Date(2000, 1, 1, 22, 0, 0, 0, time.UTC)
|
||||||
|
endTime := time.Date(2000, 1, 1, 2, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "daily",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test with a time at 23:00 (should be silenced)
|
||||||
|
// We can't control the actual current time, but we can verify the logic
|
||||||
|
// by checking if the window was created correctly
|
||||||
|
windows, err := hub.FindAllRecords("quiet_hours", dbx.HashExp{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, windows, 1, "Should have created 1 window")
|
||||||
|
|
||||||
|
window := windows[0]
|
||||||
|
assert.Equal(t, "daily", window.GetString("type"))
|
||||||
|
assert.Equal(t, 22, window.GetDateTime("start").Time().Hour())
|
||||||
|
assert.Equal(t, 2, window.GetDateTime("end").Time().Hour())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedGlobal(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create multiple systems
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 3, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a global quiet hours window (no system specified)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
// system field is empty/null for global windows
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// All systems should be silenced
|
||||||
|
for _, system := range systems {
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for system %s (global window)", system.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even with a systemID that doesn't exist, should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, "nonexistent-system")
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for any system (global window)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedSystemSpecific(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create multiple systems
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 2, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system1 := systems[0]
|
||||||
|
system2 := systems[1]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a system-specific quiet hours window for system1 only
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system1.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// System1 should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system1.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for system1")
|
||||||
|
|
||||||
|
// System2 should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user.Id, system2.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced for system2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedMultiUser(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create two users
|
||||||
|
user1, err := beszelTests.CreateUser(hub, "user1@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := beszelTests.CreateUser(hub, "user2@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a system accessible to both users
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "shared-system",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Create a quiet hours window for user1 only
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user1.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// User1 should be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user1.Id, system.Id)
|
||||||
|
assert.True(t, silenced, "Alert should be silenced for user1")
|
||||||
|
|
||||||
|
// User2 should NOT be silenced
|
||||||
|
silenced = am.IsNotificationSilenced(user2.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced for user2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Create a status alert
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
||||||
|
"name": "Status",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"min": 1,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create user settings with email
|
||||||
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", dbx.Params{"user": user.Id})
|
||||||
|
if err != nil || userSettings == nil {
|
||||||
|
userSettings, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"settings": map[string]any{
|
||||||
|
"emails": []string{"test@example.com"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a quiet hours window
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startTime := now.Add(-1 * time.Hour)
|
||||||
|
endTime := now.Add(1 * time.Hour)
|
||||||
|
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "quiet_hours", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "one-time",
|
||||||
|
"start": startTime,
|
||||||
|
"end": endTime,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Get initial email count
|
||||||
|
initialEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
|
||||||
|
// Trigger an alert by setting system to down
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed (1 minute + buffer)
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// Check that no email was sent (because alert is silenced)
|
||||||
|
finalEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
assert.Equal(t, initialEmailCount, finalEmailCount, "No emails should be sent when alert is silenced")
|
||||||
|
|
||||||
|
// Clear quiet hours windows
|
||||||
|
_, err = hub.DB().NewQuery("DELETE FROM quiet_hours").Execute()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Reset system to up, then down again
|
||||||
|
system.Set("status", "up")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
system.Set("status", "down")
|
||||||
|
err = hub.SaveNoValidate(system)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(time.Second * 75)
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
// Now an email should be sent
|
||||||
|
newEmailCount := hub.TestMailer.TotalSend()
|
||||||
|
assert.Greater(t, newEmailCount, finalEmailCount, "Email should be sent when not silenced")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertSilencedNoWindows(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system
|
||||||
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
system := systems[0]
|
||||||
|
|
||||||
|
// Get alert manager
|
||||||
|
am := alerts.NewAlertManager(hub)
|
||||||
|
defer am.StopWorker()
|
||||||
|
|
||||||
|
// Without any quiet hours windows, alert should NOT be silenced
|
||||||
|
silenced := am.IsNotificationSilenced(user.Id, system.Id)
|
||||||
|
assert.False(t, silenced, "Alert should not be silenced when no windows exist")
|
||||||
|
}
|
||||||
67
internal/alerts/alerts_smart.go
Normal file
67
internal/alerts/alerts_smart.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleSmartDeviceAlert sends alerts when a SMART device state changes from PASSED to FAILED.
|
||||||
|
// This is automatic and does not require user opt-in.
|
||||||
|
func (am *AlertManager) handleSmartDeviceAlert(e *core.RecordEvent) error {
|
||||||
|
oldState := e.Record.Original().GetString("state")
|
||||||
|
newState := e.Record.GetString("state")
|
||||||
|
|
||||||
|
// Only alert when transitioning from PASSED to FAILED
|
||||||
|
if oldState != "PASSED" || newState != "FAILED" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemID := e.Record.GetString("system")
|
||||||
|
if systemID == "" {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the system record to get the name and users
|
||||||
|
systemRecord, err := e.App.FindRecordById("systems", systemID)
|
||||||
|
if err != nil {
|
||||||
|
e.App.Logger().Error("Failed to find system for SMART alert", "err", err, "systemID", systemID)
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
deviceName := e.Record.GetString("name")
|
||||||
|
model := e.Record.GetString("model")
|
||||||
|
|
||||||
|
// Build alert message
|
||||||
|
title := fmt.Sprintf("SMART failure on %s: %s \U0001F534", systemName, deviceName)
|
||||||
|
var message string
|
||||||
|
if model != "" {
|
||||||
|
message = fmt.Sprintf("Disk %s (%s) SMART status changed to FAILED", deviceName, model)
|
||||||
|
} else {
|
||||||
|
message = fmt.Sprintf("Disk %s SMART status changed to FAILED", deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get users associated with the system
|
||||||
|
userIDs := systemRecord.GetStringSlice("users")
|
||||||
|
if len(userIDs) == 0 {
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send alert to each user
|
||||||
|
for _, userID := range userIDs {
|
||||||
|
if err := am.SendAlert(AlertMessageData{
|
||||||
|
UserID: userID,
|
||||||
|
SystemID: systemID,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
}); err != nil {
|
||||||
|
e.App.Logger().Error("Failed to send SMART alert", "err", err, "userID", userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Next()
|
||||||
|
}
|
||||||
|
|
||||||
196
internal/alerts/alerts_smart_test.go
Normal file
196
internal/alerts/alerts_smart_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSmartDeviceAlert(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sda",
|
||||||
|
"model": "Samsung SSD 970 EVO",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify no emails sent initially
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails sent initially")
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that an email was sent
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent after state changed to FAILED")
|
||||||
|
|
||||||
|
// Check the email content
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.Contains(t, lastMessage.Subject, "SMART failure on test-system")
|
||||||
|
assert.Contains(t, lastMessage.Subject, "/dev/sda")
|
||||||
|
assert.Contains(t, lastMessage.Text, "Samsung SSD 970 EVO")
|
||||||
|
assert.Contains(t, lastMessage.Text, "FAILED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertNoAlertOnNonPassedToFailed(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state UNKNOWN
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sda",
|
||||||
|
"model": "Samsung SSD 970 EVO",
|
||||||
|
"state": "UNKNOWN",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the state from UNKNOWN to FAILED - should NOT trigger alert
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify no email was sent (only PASSED -> FAILED triggers alert)
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from UNKNOWN to FAILED")
|
||||||
|
|
||||||
|
// Re-fetch the record again
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update state from FAILED to PASSED - should NOT trigger alert
|
||||||
|
smartDevice.Set("state", "PASSED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify no email was sent
|
||||||
|
assert.Zero(t, hub.TestMailer.TotalSend(), "should have 0 emails when changing from FAILED to PASSED")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertMultipleUsers(t *testing.T) {
|
||||||
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a second user
|
||||||
|
user2, err := beszelTests.CreateUser(hub, "test2@example.com", "password")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create user settings for the second user
|
||||||
|
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
||||||
|
"user": user2.Id,
|
||||||
|
"settings": `{"emails":["test2@example.com"],"webhooks":[]}`,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a system with both users
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "shared-system",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/nvme0n1",
|
||||||
|
"model": "WD Black SN850",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that two emails were sent (one for each user)
|
||||||
|
assert.EqualValues(t, 2, hub.TestMailer.TotalSend(), "should have 2 emails sent for 2 users")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmartDeviceAlertWithoutModel(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a system for the user
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a smart_device with state PASSED but no model
|
||||||
|
smartDevice, err := beszelTests.CreateRecord(hub, "smart_devices", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "/dev/sdb",
|
||||||
|
"state": "PASSED",
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-fetch the record so PocketBase can properly track original values
|
||||||
|
smartDevice, err = hub.FindRecordById("smart_devices", smartDevice.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update the smart device state to FAILED
|
||||||
|
smartDevice.Set("state", "FAILED")
|
||||||
|
err = hub.Save(smartDevice)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify that an email was sent
|
||||||
|
assert.EqualValues(t, 1, hub.TestMailer.TotalSend(), "should have 1 email sent")
|
||||||
|
|
||||||
|
// Check that the email doesn't have empty parentheses for missing model
|
||||||
|
lastMessage := hub.TestMailer.LastMessage()
|
||||||
|
assert.NotContains(t, lastMessage.Text, "()", "should not have empty parentheses for missing model")
|
||||||
|
assert.Contains(t, lastMessage.Text, "/dev/sdb")
|
||||||
|
}
|
||||||
@@ -166,6 +166,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
|
|
||||||
return am.SendAlert(AlertMessageData{
|
return am.SendAlert(AlertMessageData{
|
||||||
UserID: alertRecord.GetString("user"),
|
UserID: alertRecord.GetString("user"),
|
||||||
|
SystemID: systemID,
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.hub.MakeLink("system", systemID),
|
Link: am.hub.MakeLink("system", systemID),
|
||||||
|
|||||||
@@ -66,18 +66,31 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
unit = ""
|
unit = ""
|
||||||
case "GPU":
|
case "GPU":
|
||||||
val = data.Info.GpuPct
|
val = data.Info.GpuPct
|
||||||
|
case "Battery":
|
||||||
|
if data.Stats.Battery[0] == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = float64(data.Stats.Battery[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
threshold := alertRecord.GetFloat("value")
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
|
lowAlert := isLowAlert(name)
|
||||||
|
|
||||||
// CONTINUE
|
// CONTINUE
|
||||||
// IF alert is not triggered and curValue is less than threshold
|
// For normal alerts: IF not triggered and curValue <= threshold, OR triggered and curValue > threshold
|
||||||
// OR alert is triggered and curValue is greater than threshold
|
// For low alerts (Battery): IF not triggered and curValue >= threshold, OR triggered and curValue < threshold
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
if lowAlert {
|
||||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
if (!triggered && val >= threshold) || (triggered && val < threshold) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
|
|
||||||
@@ -94,7 +107,11 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
|
|
||||||
// send alert immediately if min is 1 - no need to sum up values.
|
// send alert immediately if min is 1 - no need to sum up values.
|
||||||
if min == 1 {
|
if min == 1 {
|
||||||
|
if lowAlert {
|
||||||
|
alert.triggered = val < threshold
|
||||||
|
} else {
|
||||||
alert.triggered = val > threshold
|
alert.triggered = val > threshold
|
||||||
|
}
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -219,6 +236,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
alert.val += maxUsage
|
alert.val += maxUsage
|
||||||
|
case "Battery":
|
||||||
|
alert.val += float64(stats.Battery[0])
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -256,6 +275,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
// pass through alert if count is greater than or equal to minCount
|
// pass through alert if count is greater than or equal to minCount
|
||||||
if float32(alert.count) >= minCount {
|
if float32(alert.count) >= minCount {
|
||||||
|
// Battery alert has inverted logic: trigger when value is BELOW threshold
|
||||||
|
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 {
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
alert.triggered = true
|
alert.triggered = true
|
||||||
go am.sendSystemAlert(alert)
|
go am.sendSystemAlert(alert)
|
||||||
@@ -265,6 +295,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,18 +312,27 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
// make title alert name lowercase if not CPU or GPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" {
|
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subject string
|
var subject string
|
||||||
|
lowAlert := isLowAlert(alert.name)
|
||||||
if alert.triggered {
|
if alert.triggered {
|
||||||
|
if lowAlert {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if lowAlert {
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
minutesLabel := "minute"
|
minutesLabel := "minute"
|
||||||
if alert.min > 1 {
|
if alert.min > 1 {
|
||||||
minutesLabel += "s"
|
minutesLabel += "s"
|
||||||
@@ -309,9 +349,14 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
}
|
}
|
||||||
am.SendAlert(AlertMessageData{
|
am.SendAlert(AlertMessageData{
|
||||||
UserID: alert.alertRecord.GetString("user"),
|
UserID: alert.alertRecord.GetString("user"),
|
||||||
|
SystemID: alert.systemRecord.Id,
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
Link: am.hub.MakeLink("system", alert.systemRecord.Id),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isLowAlert(name string) bool {
|
||||||
|
return name == "Battery"
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import (
|
|||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
listen string // listen is the address or port to listen on.
|
listen string // listen is the address or port to listen on.
|
||||||
// TODO: add hubURL and token
|
hubURL string // hubURL is the URL of the Beszel hub.
|
||||||
// hubURL string // hubURL is the URL of the hub to use.
|
token string // token is the token to use for authentication.
|
||||||
// token string // token is the token to use for authentication.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse parses the command line flags and populates the config struct.
|
// parse parses the command line flags and populates the config struct.
|
||||||
@@ -47,13 +46,13 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
// pflag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
|
||||||
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
pflag.StringVarP(&opts.key, "key", "k", "", "Public key(s) for SSH authentication")
|
||||||
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
pflag.StringVarP(&opts.listen, "listen", "l", "", "Address or port to listen on")
|
||||||
// pflag.StringVarP(&opts.hubURL, "hub-url", "u", "", "URL of the hub to use")
|
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.token, "token", "t", "", "Token to use for authentication")
|
||||||
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
chinaMirrors := pflag.BoolP("china-mirrors", "c", false, "Use mirror for update (gh.beszel.dev) instead of GitHub")
|
||||||
help := pflag.BoolP("help", "h", false, "Show this help message")
|
help := pflag.BoolP("help", "h", false, "Show this help message")
|
||||||
|
|
||||||
// Convert old single-dash long flags to double-dash for backward compatibility
|
// Convert old single-dash long flags to double-dash for backward compatibility
|
||||||
flagsToConvert := []string{"key", "listen"}
|
flagsToConvert := []string{"key", "listen", "url", "token"}
|
||||||
for i, arg := range os.Args {
|
for i, arg := range os.Args {
|
||||||
for _, flag := range flagsToConvert {
|
for _, flag := range flagsToConvert {
|
||||||
singleDash := "-" + flag
|
singleDash := "-" + flag
|
||||||
@@ -95,6 +94,13 @@ func (opts *cmdOptions) parse() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set environment variables from CLI flags (if provided)
|
||||||
|
if opts.hubURL != "" {
|
||||||
|
os.Setenv("HUB_URL", opts.hubURL)
|
||||||
|
}
|
||||||
|
if opts.token != "" {
|
||||||
|
os.Setenv("TOKEN", opts.token)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -34,14 +35,14 @@ type HubRequest[T any] struct {
|
|||||||
// AgentResponse defines the structure for responses sent from agent to hub.
|
// AgentResponse defines the structure for responses sent from agent to hub.
|
||||||
type AgentResponse struct {
|
type AgentResponse struct {
|
||||||
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
Id *uint32 `cbor:"0,keyasint,omitempty"`
|
||||||
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"`
|
SystemData *system.CombinedData `cbor:"1,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"`
|
Fingerprint *FingerprintResponse `cbor:"2,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
Error string `cbor:"3,keyasint,omitempty,omitzero"`
|
||||||
String *string `cbor:"4,keyasint,omitempty,omitzero"`
|
String *string `cbor:"4,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"`
|
SmartData map[string]smart.SmartData `cbor:"5,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"`
|
ServiceInfo systemd.ServiceDetails `cbor:"6,keyasint,omitempty,omitzero"` // Legacy (<= 0.17)
|
||||||
// Logs *LogsPayload `cbor:"4,keyasint,omitempty,omitzero"`
|
// Data is the generic response payload for new endpoints (0.18+)
|
||||||
// RawBytes []byte `cbor:"4,keyasint,omitempty,omitzero"`
|
Data cbor.RawMessage `cbor:"7,keyasint,omitempty,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintRequest struct {
|
type FingerprintRequest struct {
|
||||||
@@ -59,7 +60,7 @@ type FingerprintResponse struct {
|
|||||||
|
|
||||||
type DataRequestOptions struct {
|
type DataRequestOptions struct {
|
||||||
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
CacheTimeMs uint16 `cbor:"0,keyasint"`
|
||||||
// ResourceType uint8 `cbor:"1,keyasint,omitempty,omitzero"`
|
IncludeDetails bool `cbor:"1,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContainerLogsRequest struct {
|
type ContainerLogsRequest struct {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN rm -rf /tmp/*
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: default scratch-based agent
|
# Final image: default scratch-based agent
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:latest
|
FROM alpine:3.23
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
RUN apk add --no-cache smartmontools
|
RUN apk add --no-cache smartmontools
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-
|
|||||||
# Final image
|
# Final image
|
||||||
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:edge
|
FROM alpine:3.23
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ type ApiStats struct {
|
|||||||
MemoryStats MemoryStats `json:"memory_stats"`
|
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 {
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuContainer uint64, prevCpuSystem uint64) float64 {
|
||||||
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuContainer
|
||||||
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
systemDelta := s.CPUStats.SystemUsage - prevCpuSystem
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ type FsStats struct {
|
|||||||
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
BytesRecv uint64
|
BytesRecv uint64
|
||||||
BytesSent uint64
|
BytesSent uint64
|
||||||
@@ -124,30 +123,49 @@ const (
|
|||||||
ConnectionTypeWebSocket
|
ConnectionTypeWebSocket
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Core system data that is needed in All Systems table
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h" cbor:"0,keyasint"`
|
Hostname string `json:"h,omitempty" cbor:"0,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||||
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"`
|
KernelVersion string `json:"k,omitempty" cbor:"1,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||||
Cores int `json:"c" cbor:"2,keyasint"`
|
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
|
||||||
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
Threads int `json:"t,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
CpuModel string `json:"m" cbor:"4,keyasint"`
|
CpuModel string `json:"m,omitempty" cbor:"4,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||||
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
Uptime uint64 `json:"u" cbor:"5,keyasint"`
|
||||||
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"6,keyasint"`
|
||||||
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
MemPct float64 `json:"mp" cbor:"7,keyasint"`
|
||||||
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
DiskPct float64 `json:"dp" cbor:"8,keyasint"`
|
||||||
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
Bandwidth float64 `json:"b" cbor:"9,keyasint"`
|
||||||
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
AgentVersion string `json:"v" cbor:"10,keyasint"`
|
||||||
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"`
|
Podman bool `json:"p,omitempty" cbor:"11,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||||
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"`
|
||||||
Os Os `json:"os" cbor:"14,keyasint"`
|
Os Os `json:"os,omitempty" cbor:"14,keyasint,omitempty"` // deprecated - moved to Details struct
|
||||||
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"`
|
LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||||
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"`
|
LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||||
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"`
|
LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` // deprecated - use `la` array instead
|
||||||
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
BandwidthBytes uint64 `json:"bb" cbor:"18,keyasint"`
|
||||||
// TODO: remove load fields in future release in favor of load avg array
|
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
ExtraFsPct map[string]float64 `json:"efs,omitempty" cbor:"21,keyasint,omitempty"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
@@ -156,4 +174,5 @@ type CombinedData struct {
|
|||||||
Info Info `json:"info" cbor:"1,keyasint"`
|
Info Info `json:"info" cbor:"1,keyasint"`
|
||||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ func (acr *agentConnectRequest) agentConnect() (err error) {
|
|||||||
|
|
||||||
// Check if token is an active universal token
|
// Check if token is an active universal token
|
||||||
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
acr.userId, acr.isUniversalToken = universalTokenMap.GetMap().GetOk(acr.token)
|
||||||
|
if !acr.isUniversalToken {
|
||||||
|
// Fallback: check for a permanent universal token stored in the DB
|
||||||
|
if rec, err := acr.hub.FindFirstRecordByFilter("universal_tokens", "token = {:token}", dbx.Params{"token": acr.token}); err == nil {
|
||||||
|
if userID := rec.GetString("user"); userID != "" {
|
||||||
|
acr.userId = userID
|
||||||
|
acr.isUniversalToken = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Find matching fingerprint records for this token
|
// Find matching fingerprint records for this token
|
||||||
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
fpRecords := getFingerprintRecordsByToken(acr.token, acr.hub)
|
||||||
|
|||||||
@@ -1169,6 +1169,106 @@ func TestMultipleSystemsWithSameUniversalToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPermanentUniversalTokenFromDB verifies that a universal token persisted in the DB
|
||||||
|
// (universal_tokens collection) is accepted for agent self-registration even if it is not
|
||||||
|
// present in the in-memory universalTokenMap.
|
||||||
|
func TestPermanentUniversalTokenFromDB(t *testing.T) {
|
||||||
|
// Create hub and test app
|
||||||
|
hub, testApp, err := createTestHub(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer testApp.Cleanup()
|
||||||
|
|
||||||
|
// Get the hub's SSH key
|
||||||
|
hubSigner, err := hub.GetSSHKey("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
goodPubKey := hubSigner.PublicKey()
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
userRecord, err := createTestUser(testApp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a permanent universal token record in the DB (do NOT add it to universalTokenMap)
|
||||||
|
universalToken := "db-universal-token-123"
|
||||||
|
_, err = createTestRecord(testApp, "universal_tokens", map[string]any{
|
||||||
|
"user": userRecord.Id,
|
||||||
|
"token": universalToken,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create HTTP server with the actual API route
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/beszel/agent-connect" {
|
||||||
|
acr := &agentConnectRequest{
|
||||||
|
hub: hub,
|
||||||
|
req: r,
|
||||||
|
res: w,
|
||||||
|
}
|
||||||
|
acr.agentConnect()
|
||||||
|
} else {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
// Create and configure agent
|
||||||
|
agentDataDir := t.TempDir()
|
||||||
|
err = os.WriteFile(filepath.Join(agentDataDir, "fingerprint"), []byte("db-token-system-fingerprint"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testAgent, err := agent.NewAgent(agentDataDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up environment variables for the agent
|
||||||
|
os.Setenv("BESZEL_AGENT_HUB_URL", ts.URL)
|
||||||
|
os.Setenv("BESZEL_AGENT_TOKEN", universalToken)
|
||||||
|
defer func() {
|
||||||
|
os.Unsetenv("BESZEL_AGENT_HUB_URL")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_TOKEN")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start agent in background
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
serverOptions := agent.ServerOptions{
|
||||||
|
Network: "tcp",
|
||||||
|
Addr: "127.0.0.1:46050",
|
||||||
|
Keys: []ssh.PublicKey{goodPubKey},
|
||||||
|
}
|
||||||
|
done <- testAgent.Start(serverOptions)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for connection result
|
||||||
|
maxWait := 2 * time.Second
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
checkInterval := 20 * time.Millisecond
|
||||||
|
timeout := time.After(maxWait)
|
||||||
|
ticker := time.Tick(checkInterval)
|
||||||
|
|
||||||
|
connectionManager := testAgent.GetConnectionManager()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatalf("Expected connection to succeed but timed out - agent state: %d", connectionManager.State)
|
||||||
|
case <-ticker:
|
||||||
|
if connectionManager.State == agent.WebSocketConnected {
|
||||||
|
// Success
|
||||||
|
goto verify
|
||||||
|
}
|
||||||
|
case err := <-done:
|
||||||
|
// If Start returns early, treat it as failure
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Agent failed to start/connect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verify:
|
||||||
|
// Verify that a system was created for the user (self-registration path)
|
||||||
|
systemsAfter, err := testApp.FindRecordsByFilter("systems", "users ~ {:userId}", "", -1, 0, map[string]any{"userId": userRecord.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, systemsAfter, "Expected a system to be created for DB-backed universal token")
|
||||||
|
}
|
||||||
|
|
||||||
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
|
// TestFindOrCreateSystemForToken tests the findOrCreateSystemForToken function
|
||||||
func TestFindOrCreateSystemForToken(t *testing.T) {
|
func TestFindOrCreateSystemForToken(t *testing.T) {
|
||||||
hub, testApp, err := createTestHub(t)
|
hub, testApp, err := createTestHub(t)
|
||||||
|
|||||||
@@ -415,7 +415,11 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
|||||||
// Wait for first value to expire
|
// Wait for first value to expire
|
||||||
time.Sleep(time.Millisecond * 20)
|
time.Sleep(time.Millisecond * 20)
|
||||||
|
|
||||||
// Try to remove the expired value - should remove one of the "value1" entries
|
// Trigger lazy cleanup of the expired key
|
||||||
|
_, ok := em.GetOk("key1")
|
||||||
|
assert.False(t, ok)
|
||||||
|
|
||||||
|
// Try to remove the remaining "value1" entry (key3)
|
||||||
removedValue, ok := em.RemovebyValue("value1")
|
removedValue, ok := em.RemovebyValue("value1")
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, "value1", removedValue)
|
assert.Equal(t, "value1", removedValue)
|
||||||
@@ -423,14 +427,9 @@ func TestExpiryMap_RemoveValue_WithExpiration(t *testing.T) {
|
|||||||
// Should still have key2 (different value)
|
// Should still have key2 (different value)
|
||||||
assert.True(t, em.Has("key2"))
|
assert.True(t, em.Has("key2"))
|
||||||
|
|
||||||
// Should have removed one of the "value1" entries (either key1 or key3)
|
// key1 should be gone due to expiration and key3 should be removed by value.
|
||||||
// But we can't predict which one due to map iteration order
|
assert.False(t, em.Has("key1"))
|
||||||
key1Exists := em.Has("key1")
|
assert.False(t, em.Has("key3"))
|
||||||
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) {
|
func TestExpiryMap_ValueOperations_Integration(t *testing.T) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/users"
|
"github.com/henrygd/beszel/internal/users"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -268,8 +269,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// get SMART data
|
// refresh SMART devices for a system
|
||||||
apiAuth.GET("/smart", h.getSmartData)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
@@ -288,24 +289,90 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
|||||||
userID := e.Auth.Id
|
userID := e.Auth.Id
|
||||||
query := e.Request.URL.Query()
|
query := e.Request.URL.Query()
|
||||||
token := query.Get("token")
|
token := query.Get("token")
|
||||||
|
enable := query.Get("enable")
|
||||||
|
permanent := query.Get("permanent")
|
||||||
|
|
||||||
|
// helper for deleting any existing permanent token record for this user
|
||||||
|
deletePermanent := func() error {
|
||||||
|
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil // no record
|
||||||
|
}
|
||||||
|
return h.Delete(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper for upserting a permanent token record for this user
|
||||||
|
upsertPermanent := func(token string) error {
|
||||||
|
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
|
||||||
|
if err == nil {
|
||||||
|
rec.Set("token", token)
|
||||||
|
return h.Save(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newRec := core.NewRecord(col)
|
||||||
|
newRec.Set("user", userID)
|
||||||
|
newRec.Set("token", token)
|
||||||
|
return h.Save(newRec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable universal tokens (both ephemeral and permanent)
|
||||||
|
if enable == "0" {
|
||||||
|
tokenMap.RemovebyValue(userID)
|
||||||
|
_ = deletePermanent()
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable universal token (ephemeral or permanent)
|
||||||
|
if enable == "1" {
|
||||||
|
if token == "" {
|
||||||
|
token = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if permanent == "1" {
|
||||||
|
// make token permanent (persist across restarts)
|
||||||
|
tokenMap.RemovebyValue(userID)
|
||||||
|
if err := upsertPermanent(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: ephemeral mode (1 hour)
|
||||||
|
_ = deletePermanent()
|
||||||
|
tokenMap.Set(token, userID, time.Hour)
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read current state
|
||||||
|
// Prefer permanent token if it exists.
|
||||||
|
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
|
||||||
|
dbToken := rec.GetString("token")
|
||||||
|
// If no token was provided, or the caller is asking about their permanent token, return it.
|
||||||
|
if token == "" || token == dbToken {
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
|
||||||
|
}
|
||||||
|
// Token doesn't match their permanent token (avoid leaking other info)
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// No permanent token; fall back to ephemeral token map.
|
||||||
if token == "" {
|
if token == "" {
|
||||||
// return existing token if it exists
|
// return existing token if it exists
|
||||||
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||||
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
|
||||||
}
|
}
|
||||||
// if no token is provided, generate a new one
|
// if no token is provided, generate a new one
|
||||||
token = uuid.New().String()
|
token = uuid.New().String()
|
||||||
}
|
}
|
||||||
response := map[string]any{"token": token}
|
|
||||||
|
|
||||||
switch query.Get("enable") {
|
// Token is considered active only if it belongs to the current user.
|
||||||
case "1":
|
activeUser, ok := tokenMap.GetOk(token)
|
||||||
tokenMap.Set(token, userID, time.Hour)
|
active := ok && activeUser == userID
|
||||||
case "0":
|
response := map[string]any{"token": token, "active": active, "permanent": false}
|
||||||
tokenMap.RemovebyValue(userID)
|
|
||||||
}
|
|
||||||
_, response["active"] = tokenMap.GetOk(token)
|
|
||||||
return e.JSON(http.StatusOK, response)
|
return e.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,22 +432,25 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSmartData handles GET /api/beszel/smart requests
|
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||||
func (h *Hub) getSmartData(e *core.RequestEvent) error {
|
// Fetches fresh SMART data from the agent and updates the collection
|
||||||
|
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
if systemID == "" {
|
if systemID == "" {
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||||
}
|
}
|
||||||
data, err := system.FetchSmartDataFromAgent()
|
|
||||||
if err != nil {
|
// Fetch and save SMART devices
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
||||||
|
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
|
||||||
return e.JSON(http.StatusOK, data)
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns signer
|
// generates key pair if it doesn't exist and returns signer
|
||||||
|
|||||||
@@ -378,7 +378,18 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 200,
|
ExpectedStatus: 200,
|
||||||
ExpectedContent: []string{"active", "token"},
|
ExpectedContent: []string{"active", "token", "permanent"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - enable permanent should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/transport"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
|
"github.com/lxzan/gws"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -34,12 +38,17 @@ type System struct {
|
|||||||
Status string `db:"status"`
|
Status string `db:"status"`
|
||||||
manager *SystemManager // Manager that this system belongs to
|
manager *SystemManager // Manager that this system belongs to
|
||||||
client *ssh.Client // SSH client for fetching data
|
client *ssh.Client // SSH client for fetching data
|
||||||
|
sshTransport *transport.SSHTransport // SSH transport for requests
|
||||||
data *system.CombinedData // system data from agent
|
data *system.CombinedData // system data from agent
|
||||||
ctx context.Context // Context for stopping the updater
|
ctx context.Context // Context for stopping the updater
|
||||||
cancel context.CancelFunc // Stops and removes system from updater
|
cancel context.CancelFunc // Stops and removes system from updater
|
||||||
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
WsConn *ws.WsConn // Handler for agent WebSocket connection
|
||||||
agentVersion semver.Version // Agent version
|
agentVersion semver.Version // Agent version
|
||||||
updateTicker *time.Ticker // Ticker for updating the system
|
updateTicker *time.Ticker // Ticker for updating the system
|
||||||
|
detailsFetched atomic.Bool // True if static system details have been fetched and saved
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) NewSystem(systemId string) *System {
|
func (sm *SystemManager) NewSystem(systemId string) *System {
|
||||||
@@ -112,10 +121,37 @@ func (sys *System) update() error {
|
|||||||
sys.handlePaused()
|
sys.handlePaused()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
data, err := sys.fetchDataFromAgent(common.DataRequestOptions{CacheTimeMs: uint16(interval)})
|
options := common.DataRequestOptions{
|
||||||
if err == nil {
|
CacheTimeMs: uint16(interval),
|
||||||
_, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +176,11 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
hub := sys.manager.hub
|
hub := sys.manager.hub
|
||||||
err = hub.RunInTransaction(func(txApp core.App) error {
|
err = hub.RunInTransaction(func(txApp core.App) error {
|
||||||
// add system_stats and container_stats records
|
// add system_stats record
|
||||||
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
|
systemStatsCollection, err := txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
systemStatsRecord := core.NewRecord(systemStatsCollection)
|
||||||
systemStatsRecord.Set("system", systemRecord.Id)
|
systemStatsRecord.Set("system", systemRecord.Id)
|
||||||
systemStatsRecord.Set("stats", data.Stats)
|
systemStatsRecord.Set("stats", data.Stats)
|
||||||
@@ -153,14 +188,14 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
|
if err := txApp.SaveNoValidate(systemStatsRecord); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add containers and container_stats records
|
||||||
if len(data.Containers) > 0 {
|
if len(data.Containers) > 0 {
|
||||||
// add / update containers records
|
|
||||||
if data.Containers[0].Id != "" {
|
if data.Containers[0].Id != "" {
|
||||||
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
|
if err := createContainerRecords(txApp, data.Containers, sys.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// add new container_stats record
|
|
||||||
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
|
containerStatsCollection, err := txApp.FindCachedCollectionByNameOrId("container_stats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -181,9 +216,20 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||||
systemRecord.Set("status", up)
|
systemRecord.Set("status", up)
|
||||||
|
|
||||||
systemRecord.Set("info", data.Info)
|
systemRecord.Set("info", data.Info)
|
||||||
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
if err := txApp.SaveNoValidate(systemRecord); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -194,6 +240,31 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
|||||||
return systemRecord, err
|
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 {
|
func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -208,7 +279,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
for i, service := range data {
|
for i, service := range data {
|
||||||
suffix := fmt.Sprintf("%d", i)
|
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))
|
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] = getSystemdServiceId(systemId, service.Name)
|
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||||
params["name"+suffix] = service.Name
|
params["name"+suffix] = service.Name
|
||||||
params["state"+suffix] = service.State
|
params["state"+suffix] = service.State
|
||||||
params["sub"+suffix] = service.Sub
|
params["sub"+suffix] = service.Sub
|
||||||
@@ -225,13 +296,6 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSystemdServiceId generates a deterministic unique id for a systemd service
|
|
||||||
func getSystemdServiceId(systemId string, serviceName string) string {
|
|
||||||
hash := fnv.New32a()
|
|
||||||
hash.Write([]byte(systemId + serviceName))
|
|
||||||
return fmt.Sprintf("%x", hash.Sum32())
|
|
||||||
}
|
|
||||||
|
|
||||||
// createContainerRecords creates container records
|
// createContainerRecords creates container records
|
||||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
@@ -299,8 +363,78 @@ func (sys *System) getContext() (context.Context, context.CancelFunc) {
|
|||||||
return sys.ctx, sys.cancel
|
return sys.ctx, sys.cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDataFromAgent attempts to fetch data from the agent,
|
// request sends a request to the agent, trying WebSocket first, then SSH.
|
||||||
// prioritizing WebSocket if available.
|
// 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.
|
||||||
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
func (sys *System) fetchDataFromAgent(options common.DataRequestOptions) (*system.CombinedData, error) {
|
||||||
if sys.data == nil {
|
if sys.data == nil {
|
||||||
sys.data = &system.CombinedData{}
|
sys.data = &system.CombinedData{}
|
||||||
@@ -326,154 +460,58 @@ func (sys *System) fetchDataViaWebSocket(options common.DataRequestOptions) (*sy
|
|||||||
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
if sys.WsConn == nil || !sys.WsConn.IsConnected() {
|
||||||
return nil, errors.New("no websocket connection")
|
return nil, errors.New("no websocket connection")
|
||||||
}
|
}
|
||||||
err := sys.WsConn.RequestSystemData(context.Background(), sys.data, options)
|
wsTransport := transport.NewWebSocketTransport(sys.WsConn)
|
||||||
|
err := wsTransport.Request(context.Background(), common.GetData, options, sys.data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return sys.data, nil
|
return sys.data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchStringFromAgentViaSSH is a generic function to fetch strings via SSH
|
|
||||||
func (sys *System) fetchStringFromAgentViaSSH(action common.WebSocketAction, requestData any, errorMsg string) (string, error) {
|
|
||||||
var result string
|
|
||||||
err := sys.runSSHOperation(4*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
|
||||||
stdout, err := session.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
stdin, stdinErr := session.StdinPipe()
|
|
||||||
if stdinErr != nil {
|
|
||||||
return false, stdinErr
|
|
||||||
}
|
|
||||||
if err := session.Shell(); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
req := common.HubRequest[any]{Action: action, Data: requestData}
|
|
||||||
_ = cbor.NewEncoder(stdin).Encode(req)
|
|
||||||
_ = stdin.Close()
|
|
||||||
var resp common.AgentResponse
|
|
||||||
err = cbor.NewDecoder(stdout).Decode(&resp)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.String == nil {
|
|
||||||
return false, errors.New(errorMsg)
|
|
||||||
}
|
|
||||||
result = *resp.String
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FetchContainerInfoFromAgent fetches container info from the agent
|
// FetchContainerInfoFromAgent fetches container info from the agent
|
||||||
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
func (sys *System) FetchContainerInfoFromAgent(containerID string) (string, error) {
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return sys.WsConn.RequestContainerInfo(ctx, containerID)
|
var result string
|
||||||
}
|
err := sys.request(ctx, common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, &result)
|
||||||
// fetch via SSH
|
return result, err
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerInfo, common.ContainerInfoRequest{ContainerID: containerID}, "no info in response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchContainerLogsFromAgent fetches container logs from the agent
|
// FetchContainerLogsFromAgent fetches container logs from the agent
|
||||||
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
func (sys *System) FetchContainerLogsFromAgent(containerID string) (string, error) {
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return sys.WsConn.RequestContainerLogs(ctx, containerID)
|
var result string
|
||||||
}
|
err := sys.request(ctx, common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, &result)
|
||||||
// fetch via SSH
|
return result, err
|
||||||
return sys.fetchStringFromAgentViaSSH(common.GetContainerLogs, common.ContainerLogsRequest{ContainerID: containerID}, "no logs in response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
// FetchSystemdInfoFromAgent fetches detailed systemd service information from the agent
|
||||||
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
func (sys *System) FetchSystemdInfoFromAgent(serviceName string) (systemd.ServiceDetails, error) {
|
||||||
// fetch via websocket
|
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return sys.WsConn.RequestSystemdInfo(ctx, serviceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
var result systemd.ServiceDetails
|
||||||
err := sys.runSSHOperation(5*time.Second, 1, func(session *ssh.Session) (bool, error) {
|
err := sys.request(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName}, &result)
|
||||||
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.GetSystemdInfo, Data: common.SystemdInfoRequest{ServiceName: serviceName}}
|
|
||||||
if err := cbor.NewEncoder(stdin).Encode(req); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
_ = stdin.Close()
|
|
||||||
|
|
||||||
var resp common.AgentResponse
|
|
||||||
if err := cbor.NewDecoder(stdout).Decode(&resp); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if resp.ServiceInfo == nil {
|
|
||||||
if resp.Error != "" {
|
|
||||||
return false, errors.New(resp.Error)
|
|
||||||
}
|
|
||||||
return false, errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
result = resp.ServiceInfo
|
|
||||||
return false, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchSmartDataFromAgent fetches SMART data from the agent
|
// FetchSmartDataFromAgent fetches SMART data from the agent
|
||||||
func (sys *System) FetchSmartDataFromAgent() (map[string]any, error) {
|
func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error) {
|
||||||
// fetch via websocket
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
if sys.WsConn != nil && sys.WsConn.IsConnected() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return sys.WsConn.RequestSmartData(ctx)
|
var result map[string]smart.SmartData
|
||||||
}
|
err := sys.request(ctx, common.GetSmartData, nil, &result)
|
||||||
// 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
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeStableHashId(strings ...string) string {
|
||||||
|
hash := fnv.New32a()
|
||||||
|
for _, str := range strings {
|
||||||
|
hash.Write([]byte(str))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x", hash.Sum32())
|
||||||
|
}
|
||||||
|
|
||||||
// fetchDataViaSSH handles fetching data using SSH.
|
// fetchDataViaSSH handles fetching data using SSH.
|
||||||
// This function encapsulates the original SSH logic.
|
// This function encapsulates the original SSH logic.
|
||||||
// It updates sys.data directly upon successful fetch.
|
// It updates sys.data directly upon successful fetch.
|
||||||
@@ -627,6 +665,9 @@ func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session
|
|||||||
|
|
||||||
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
// closeSSHConnection closes the SSH connection but keeps the system in the manager
|
||||||
func (sys *System) closeSSHConnection() {
|
func (sys *System) closeSSHConnection() {
|
||||||
|
if sys.sshTransport != nil {
|
||||||
|
sys.sshTransport.Close()
|
||||||
|
}
|
||||||
if sys.client != nil {
|
if sys.client != nil {
|
||||||
sys.client.Close()
|
sys.client.Close()
|
||||||
sys.client = nil
|
sys.client = nil
|
||||||
|
|||||||
92
internal/hub/systems/system_smart.go
Normal file
92
internal/hub/systems/system_smart.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName := "nginx.service"
|
serviceName := "nginx.service"
|
||||||
|
|
||||||
// Call multiple times and ensure same result
|
// Call multiple times and ensure same result
|
||||||
id1 := getSystemdServiceId(systemId, serviceName)
|
id1 := makeStableHashId(systemId, serviceName)
|
||||||
id2 := getSystemdServiceId(systemId, serviceName)
|
id2 := makeStableHashId(systemId, serviceName)
|
||||||
id3 := getSystemdServiceId(systemId, serviceName)
|
id3 := makeStableHashId(systemId, serviceName)
|
||||||
|
|
||||||
assert.Equal(t, id1, id2)
|
assert.Equal(t, id1, id2)
|
||||||
assert.Equal(t, id2, id3)
|
assert.Equal(t, id2, id3)
|
||||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
serviceName1 := "nginx.service"
|
serviceName1 := "nginx.service"
|
||||||
serviceName2 := "apache.service"
|
serviceName2 := "apache.service"
|
||||||
|
|
||||||
id1 := getSystemdServiceId(systemId1, serviceName1)
|
id1 := makeStableHashId(systemId1, serviceName1)
|
||||||
id2 := getSystemdServiceId(systemId2, serviceName1)
|
id2 := makeStableHashId(systemId2, serviceName1)
|
||||||
id3 := getSystemdServiceId(systemId1, serviceName2)
|
id3 := makeStableHashId(systemId1, serviceName2)
|
||||||
id4 := getSystemdServiceId(systemId2, serviceName2)
|
id4 := makeStableHashId(systemId2, serviceName2)
|
||||||
|
|
||||||
// All IDs should be different
|
// All IDs should be different
|
||||||
assert.NotEqual(t, id1, id2)
|
assert.NotEqual(t, id1, id2)
|
||||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
id := getSystemdServiceId(tc.systemId, tc.serviceName)
|
id := makeStableHashId(tc.systemId, tc.serviceName)
|
||||||
// FNV-32 produces 8 hex characters
|
// 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)
|
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) {
|
t.Run("hexadecimal output", func(t *testing.T) {
|
||||||
id := getSystemdServiceId("test-system", "test-service")
|
id := makeStableHashId("test-system", "test-service")
|
||||||
assert.NotEmpty(t, id)
|
assert.NotEmpty(t, id)
|
||||||
|
|
||||||
// Should only contain hexadecimal characters
|
// Should only contain hexadecimal characters
|
||||||
|
|||||||
10
internal/hub/systems/systems_production.go
Normal file
10
internal/hub/systems/systems_production.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//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 }
|
||||||
@@ -266,12 +266,14 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
|||||||
|
|
||||||
// Create test system data
|
// Create test system data
|
||||||
testData := &system.CombinedData{
|
testData := &system.CombinedData{
|
||||||
Info: system.Info{
|
Details: &system.Details{
|
||||||
Hostname: "data-test.example.com",
|
Hostname: "data-test.example.com",
|
||||||
KernelVersion: "5.15.0-generic",
|
Kernel: "5.15.0-generic",
|
||||||
Cores: 4,
|
Cores: 4,
|
||||||
Threads: 8,
|
Threads: 8,
|
||||||
CpuModel: "Test CPU",
|
CpuModel: "Test CPU",
|
||||||
|
},
|
||||||
|
Info: system.Info{
|
||||||
Uptime: 3600,
|
Uptime: 3600,
|
||||||
Cpu: 25.5,
|
Cpu: 25.5,
|
||||||
MemPct: 40.2,
|
MemPct: 40.2,
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import (
|
|||||||
entities "github.com/henrygd/beszel/internal/entities/system"
|
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
|
// TESTING ONLY: GetSystemCount returns the number of systems in the store
|
||||||
func (sm *SystemManager) GetSystemCount() int {
|
func (sm *SystemManager) GetSystemCount() int {
|
||||||
return sm.systems.Length()
|
return sm.systems.Length()
|
||||||
|
|||||||
227
internal/hub/transport/ssh.go
Normal file
227
internal/hub/transport/ssh.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
112
internal/hub/transport/transport.go
Normal file
112
internal/hub/transport/transport.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
74
internal/hub/transport/websocket.go
Normal file
74
internal/hub/transport/websocket.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,12 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResponseHandler defines interface for handling agent responses
|
// ResponseHandler defines interface for handling agent responses.
|
||||||
|
// This is used by handleAgentRequest for legacy response handling.
|
||||||
type ResponseHandler interface {
|
type ResponseHandler interface {
|
||||||
Handle(agentResponse common.AgentResponse) error
|
Handle(agentResponse common.AgentResponse) error
|
||||||
HandleLegacy(rawData []byte) error
|
HandleLegacy(rawData []byte) error
|
||||||
@@ -26,172 +25,7 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSystemdInfo requests detailed information about a systemd service via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSystemdInfo(ctx context.Context, serviceName string) (systemd.ServiceDetails, error) {
|
|
||||||
if !ws.IsConnected() {
|
|
||||||
return nil, gws.ErrConnClosed
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := ws.requestManager.SendRequest(ctx, common.GetSystemdInfo, common.SystemdInfoRequest{ServiceName: serviceName})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result systemd.ServiceDetails
|
|
||||||
handler := &systemdInfoHandler{result: &result}
|
|
||||||
if err := ws.handleAgentRequest(req, handler); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemdInfoHandler parses ServiceDetails from AgentResponse
|
|
||||||
type systemdInfoHandler struct {
|
|
||||||
BaseHandler
|
|
||||||
result *systemd.ServiceDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *systemdInfoHandler) Handle(agentResponse common.AgentResponse) error {
|
|
||||||
if agentResponse.ServiceInfo == nil {
|
|
||||||
return errors.New("no systemd info in response")
|
|
||||||
}
|
|
||||||
*h.result = agentResponse.ServiceInfo
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// RequestSmartData requests SMART data via WebSocket.
|
|
||||||
func (ws *WsConn) RequestSmartData(ctx context.Context) (map[string]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
|
// fingerprintHandler implements ResponseHandler for fingerprint requests
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package ws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerSuccess(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test successful handling with valid ServiceInfo
|
|
||||||
testDetails := systemd.ServiceDetails{
|
|
||||||
"Id": "nginx.service",
|
|
||||||
"ActiveState": "active",
|
|
||||||
"SubState": "running",
|
|
||||||
"Description": "A high performance web server",
|
|
||||||
"ExecMainPID": 1234,
|
|
||||||
"MemoryCurrent": 1024000,
|
|
||||||
}
|
|
||||||
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: testDetails,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, testDetails, *handler.result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerError(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test error handling when ServiceInfo is nil
|
|
||||||
response := common.AgentResponse{
|
|
||||||
ServiceInfo: nil,
|
|
||||||
Error: "service not found",
|
|
||||||
}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerEmptyResponse(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with completely empty response
|
|
||||||
response := common.AgentResponse{}
|
|
||||||
|
|
||||||
err := handler.Handle(response)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "no systemd info in response", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdInfoHandlerLegacyNotSupported(t *testing.T) {
|
|
||||||
handler := &systemdInfoHandler{
|
|
||||||
result: &systemd.ServiceDetails{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that legacy format is not supported
|
|
||||||
err := handler.HandleLegacy([]byte("some data"))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Equal(t, "legacy format not supported", err.Error())
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,15 @@ func NewRequestManager(conn *gws.Conn) *RequestManager {
|
|||||||
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
func (rm *RequestManager) SendRequest(ctx context.Context, action common.WebSocketAction, data any) (*PendingRequest, error) {
|
||||||
reqID := RequestID(rm.nextID.Add(1))
|
reqID := RequestID(rm.nextID.Add(1))
|
||||||
|
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
req := &PendingRequest{
|
req := &PendingRequest{
|
||||||
ID: reqID,
|
ID: reqID,
|
||||||
@@ -100,6 +108,11 @@ func (rm *RequestManager) handleResponse(message *gws.Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.Id == nil {
|
||||||
|
rm.routeLegacyResponse(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
reqID := RequestID(*response.Id)
|
reqID := RequestID(*response.Id)
|
||||||
|
|
||||||
rm.RLock()
|
rm.RLock()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
"weak"
|
"weak"
|
||||||
@@ -161,3 +162,14 @@ func (ws *WsConn) handleAgentRequest(req *PendingRequest, handler ResponseHandle
|
|||||||
func (ws *WsConn) IsConnected() bool {
|
func (ws *WsConn) IsConnected() bool {
|
||||||
return ws.conn != nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,14 +184,18 @@ func TestCommonActions(t *testing.T) {
|
|||||||
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
assert.Equal(t, common.WebSocketAction(2), common.GetContainerLogs, "GetLogs should be action 2")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogsHandler(t *testing.T) {
|
func TestFingerprintHandler(t *testing.T) {
|
||||||
h := &stringResponseHandler{errorMsg: "no logs in response"}
|
var result common.FingerprintResponse
|
||||||
|
h := &fingerprintHandler{result: &result}
|
||||||
|
|
||||||
logValue := "test logs"
|
resp := common.AgentResponse{Fingerprint: &common.FingerprintResponse{
|
||||||
resp := common.AgentResponse{String: &logValue}
|
Fingerprint: "test-fingerprint",
|
||||||
|
Hostname: "test-host",
|
||||||
|
}}
|
||||||
err := h.Handle(resp)
|
err := h.Handle(resp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, logValue, h.value)
|
assert.Equal(t, "test-fingerprint", result.Fingerprint)
|
||||||
|
assert.Equal(t, "test-host", result.Hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandler tests that we can create a Handler
|
// TestHandler tests that we can create a Handler
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ func init() {
|
|||||||
"GPU",
|
"GPU",
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15",
|
||||||
|
"Battery"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1150,6 +1151,540 @@ func init() {
|
|||||||
"type": "base",
|
"type": "base",
|
||||||
"updateRule": null,
|
"updateRule": null,
|
||||||
"viewRule": 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"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": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1597481275",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "token",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_3383022248",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX ` + "`" + `idx_iaD9Y2Lgbl` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `token` + "`" + `)",
|
||||||
|
"CREATE UNIQUE INDEX ` + "`" + `idx_wdR0A4PbRG` + "`" + ` ON ` + "`" + `universal_tokens` + "`" + ` (` + "`" + `user` + "`" + `)"
|
||||||
|
],
|
||||||
|
"listRule": null,
|
||||||
|
"name": "universal_tokens",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": null,
|
||||||
|
"viewRule": null
|
||||||
}
|
}
|
||||||
]`
|
]`
|
||||||
|
|
||||||
@@ -498,6 +498,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = deleteOldQuietHours(txApp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -591,6 +595,17 @@ func deleteOldContainerRecords(app core.App) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes old quiet hours records where end date has passed
|
||||||
|
func deleteOldQuietHours(app core.App) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"a11y": {
|
||||||
|
"useButtonType": "off"
|
||||||
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noUselessStringConcat": "error",
|
"noUselessStringConcat": "error",
|
||||||
"noUselessUndefinedInitialization": "error",
|
"noUselessUndefinedInitialization": "error",
|
||||||
@@ -30,13 +33,14 @@
|
|||||||
"noUnusedFunctionParameters": "error",
|
"noUnusedFunctionParameters": "error",
|
||||||
"noUnusedPrivateClassMembers": "error",
|
"noUnusedPrivateClassMembers": "error",
|
||||||
"useExhaustiveDependencies": {
|
"useExhaustiveDependencies": {
|
||||||
"level": "error",
|
"level": "off"
|
||||||
"options": {
|
|
||||||
"reportUnnecessaryDependencies": false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
"useUniqueElementIds": "off",
|
||||||
"noUnusedVariables": "error"
|
"noUnusedVariables": "error"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"noDangerouslySetInnerHtml": "warn"
|
||||||
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"noParameterProperties": "error",
|
"noParameterProperties": "error",
|
||||||
"noYodaExpression": "error",
|
"noYodaExpression": "error",
|
||||||
@@ -47,7 +51,8 @@
|
|||||||
},
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"useAwait": "error",
|
"useAwait": "error",
|
||||||
"noEvolvingTypes": "error"
|
"noEvolvingTypes": "error",
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
996
internal/site/bun.lock
Normal file
996
internal/site/bun.lock
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
{
|
||||||
|
"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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en" dir="ltr">
|
<html lang="en" dir="ltr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" />
|
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default defineConfig({
|
|||||||
"he",
|
"he",
|
||||||
"hr",
|
"hr",
|
||||||
"hu",
|
"hu",
|
||||||
|
"id",
|
||||||
"it",
|
"it",
|
||||||
"ja",
|
"ja",
|
||||||
"ko",
|
"ko",
|
||||||
@@ -24,6 +25,7 @@ export default defineConfig({
|
|||||||
"tr",
|
"tr",
|
||||||
"ru",
|
"ru",
|
||||||
"sl",
|
"sl",
|
||||||
|
"sr",
|
||||||
"sv",
|
"sv",
|
||||||
"uk",
|
"uk",
|
||||||
"vi",
|
"vi",
|
||||||
|
|||||||
335
internal/site/package-lock.json
generated
335
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.15.4",
|
"version": "0.18.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.15.4",
|
"version": "0.18.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
@@ -39,8 +39,8 @@
|
|||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.2",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.2",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -986,6 +987,29 @@
|
|||||||
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/balanced-match": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@isaacs/brace-expansion": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/balanced-match": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1114,6 +1138,7 @@
|
|||||||
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
|
"integrity": "sha512-9IO+PDvdneY8OCI8zvI1oDXpzryTMtyRv7uq9O0U1mFCvIPVd5dWQKQDu/CpgpYAc2+JG/izn5PNl9xzPc6ckw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.20.12",
|
"@babel/core": "^7.20.12",
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
@@ -1206,30 +1231,6 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lingui/cli/node_modules/glob": {
|
|
||||||
"version": "11.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
|
|
||||||
"integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"foreground-child": "^3.1.0",
|
|
||||||
"jackspeak": "^4.0.1",
|
|
||||||
"minimatch": "^10.0.0",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"package-json-from-dist": "^1.0.0",
|
|
||||||
"path-scurry": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"glob": "dist/esm/bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/glob-parent": {
|
"node_modules/@lingui/cli/node_modules/glob-parent": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
@@ -1243,65 +1244,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lingui/cli/node_modules/jackspeak": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/cliui": "^8.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/lru-cache": {
|
|
||||||
"version": "11.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
|
|
||||||
"integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/minimatch": {
|
|
||||||
"version": "10.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
|
|
||||||
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/path-scurry": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"lru-cache": "^11.0.0",
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@lingui/cli/node_modules/picomatch": {
|
"node_modules/@lingui/cli/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
@@ -1350,6 +1292,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@lingui/core/-/core-5.4.1.tgz",
|
||||||
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
|
"integrity": "sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.20.13",
|
||||||
"@lingui/message-utils": "5.4.1"
|
"@lingui/message-utils": "5.4.1"
|
||||||
@@ -3545,6 +3488,7 @@
|
|||||||
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -3555,6 +3499,7 @@
|
|||||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
@@ -3606,9 +3551,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.0.1",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3680,13 +3625,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -3733,16 +3671,6 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
@@ -3776,6 +3704,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -4486,13 +4415,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.2.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "^7.0.0",
|
"cross-spawn": "^7.0.6",
|
||||||
"signal-exit": "^4.0.1"
|
"signal-exit": "^4.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -4536,6 +4465,30 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.3.1",
|
||||||
|
"jackspeak": "^4.1.1",
|
||||||
|
"minimatch": "^10.1.1",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -4756,6 +4709,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jest-get-type": {
|
"node_modules/jest-get-type": {
|
||||||
"version": "29.6.3",
|
"version": "29.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
|
||||||
@@ -4807,9 +4776,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5180,9 +5149,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mdast-util-to-hast": {
|
"node_modules/mdast-util-to-hast": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
||||||
"integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==",
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^3.0.0",
|
"@types/hast": "^3.0.0",
|
||||||
@@ -5326,6 +5295,22 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||||
|
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/brace-expansion": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -5408,6 +5393,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
}
|
}
|
||||||
@@ -5567,6 +5553,33 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^11.0.0",
|
||||||
|
"minipass": "^7.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-scurry/node_modules/lru-cache": {
|
||||||
|
"version": "11.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||||
|
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-type": {
|
"node_modules/path-type": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
@@ -5590,6 +5603,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5731,24 +5745,26 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.2.tgz",
|
||||||
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
|
"integrity": "sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.2.tgz",
|
||||||
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
|
"integrity": "sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.1.1"
|
"react": "^19.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
@@ -6171,6 +6187,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -6191,16 +6217,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string-width-cjs/node_modules/strip-ansi/node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stringify-entities": {
|
"node_modules/stringify-entities": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||||
@@ -6216,9 +6232,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||||
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
|
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6283,7 +6299,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
@@ -6405,6 +6422,7 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6639,6 +6657,7 @@
|
|||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6790,6 +6809,23 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -6805,13 +6841,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/string-width/node_modules/emoji-regex": {
|
|
||||||
"version": "8.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@@ -6825,20 +6854,10 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi/node_modules/ansi-regex": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.4",
|
"version": "0.18.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
"lucide-react": "^0.452.0",
|
"lucide-react": "^0.452.0",
|
||||||
"nanostores": "^0.11.4",
|
"nanostores": "^0.11.4",
|
||||||
"pocketbase": "^0.26.2",
|
"pocketbase": "^0.26.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.2",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.2",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const ActiveAlerts = () => {
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{alert.name === "Status" ? (
|
{alert.name === "Status" ? (
|
||||||
<Trans>Connection is down</Trans>
|
<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>
|
<Trans>
|
||||||
Exceeds {alert.value}
|
Exceeds {alert.value}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { msg, t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
@@ -36,6 +36,9 @@ import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/i
|
|||||||
import { InputCopy } from "./ui/input-copy"
|
import { InputCopy } from "./ui/input-copy"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
|
if (isReadOnlyUser()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const opened = useRef(false)
|
const opened = useRef(false)
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -45,10 +48,7 @@ export function AddSystemButton({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
|
||||||
variant="outline"
|
|
||||||
className={cn("flex gap-1 max-xs:h-[2.4rem]", className, isReadOnlyUser() && "hidden")}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
<Trans>
|
<Trans>
|
||||||
Add <span className="hidden sm:inline">System</span>
|
Add <span className="hidden sm:inline">System</span>
|
||||||
@@ -124,6 +124,8 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const systemTranslation = t`System`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
className="w-[90%] sm:w-auto sm:ns-dialog max-w-full rounded-lg"
|
||||||
@@ -134,7 +136,11 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
|
|||||||
<Tabs defaultValue={tab} onValueChange={setTab}>
|
<Tabs defaultValue={tab} onValueChange={setTab}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
<DialogTitle className="mb-1 pb-1 max-w-100 truncate pr-8">
|
||||||
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
|
{system ? (
|
||||||
|
<Trans>Edit {{ foo: systemTranslation }}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="docker">Docker</TabsTrigger>
|
<TabsTrigger value="docker">Docker</TabsTrigger>
|
||||||
|
|||||||
@@ -245,6 +245,15 @@ export function AlertContent({
|
|||||||
{!singleDescription && (
|
{!singleDescription && (
|
||||||
<div>
|
<div>
|
||||||
<p id={`v${name}`} className="text-sm block h-8">
|
<p id={`v${name}`} className="text-sm block h-8">
|
||||||
|
{alertData.invert ? (
|
||||||
|
<Trans>
|
||||||
|
Average drops below{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{value}
|
||||||
|
{alertData.unit}
|
||||||
|
</strong>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
<Trans>
|
<Trans>
|
||||||
Average exceeds{" "}
|
Average exceeds{" "}
|
||||||
<strong className="text-foreground">
|
<strong className="text-foreground">
|
||||||
@@ -252,6 +261,7 @@ export function AlertContent({
|
|||||||
{alertData.unit}
|
{alertData.unit}
|
||||||
</strong>
|
</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
|
||||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||||
import { useYAxisWidth } from "./hooks"
|
import { useYAxisWidth } from "./hooks"
|
||||||
|
import { AxisDomain } from "recharts/types/util/types"
|
||||||
|
|
||||||
export type DataPoint = {
|
export type DataPoint = {
|
||||||
label: string
|
label: string
|
||||||
@@ -41,7 +42,7 @@ export default function AreaChartDefault({
|
|||||||
tickFormatter: (value: number, index: number) => string
|
tickFormatter: (value: number, index: number) => string
|
||||||
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
|
||||||
dataPoints?: DataPoint[]
|
dataPoints?: DataPoint[]
|
||||||
domain?: [number, number]
|
domain?: AxisDomain
|
||||||
legend?: boolean
|
legend?: boolean
|
||||||
showTotal?: boolean
|
showTotal?: boolean
|
||||||
itemSorter?: (a: any, b: any) => number
|
itemSorter?: (a: any, b: any) => number
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
|
||||||
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
|
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, pinnedAxisDomain, xAxis } from "@/components/ui/chart"
|
||||||
import { ChartType, Unit } from "@/lib/enums"
|
import { ChartType, Unit } from "@/lib/enums"
|
||||||
import { $containerFilter, $userSettings } from "@/lib/stores"
|
import { $containerFilter, $userSettings } from "@/lib/stores"
|
||||||
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
|
||||||
@@ -124,6 +124,7 @@ export default memo(function ContainerChart({
|
|||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
<YAxis
|
<YAxis
|
||||||
direction="ltr"
|
direction="ltr"
|
||||||
|
domain={pinnedAxisDomain()}
|
||||||
orientation={chartData.orientation}
|
orientation={chartData.orientation}
|
||||||
className="tracking-tighter"
|
className="tracking-tighter"
|
||||||
width={yAxisWidth}
|
width={yAxisWidth}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
FingerprintIcon,
|
FingerprintIcon,
|
||||||
LayoutDashboard,
|
HardDriveIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
Server,
|
Server,
|
||||||
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@@ -81,15 +82,15 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
)}
|
)}
|
||||||
<CommandGroup heading={t`Pages / Settings`}>
|
<CommandGroup heading={t`Pages / Settings`}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
keywords={["home", t`All Systems`]}
|
keywords={["home"]}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(basePath)
|
navigate(basePath)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="me-2 size-4" />
|
<ServerIcon className="me-2 size-4" />
|
||||||
<span>
|
<span>
|
||||||
<Trans>Dashboard</Trans>
|
<Trans>All Systems</Trans>
|
||||||
</span>
|
</span>
|
||||||
<CommandShortcut>
|
<CommandShortcut>
|
||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
@@ -109,6 +110,18 @@ export default memo(function CommandPalette({ open, setOpen }: { open: boolean;
|
|||||||
<Trans>Page</Trans>
|
<Trans>Page</Trans>
|
||||||
</CommandShortcut>
|
</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
navigate(getPagePath($router, "smart"))
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HardDriveIcon className="me-2 size-4" />
|
||||||
|
<span>S.M.A.R.T.</span>
|
||||||
|
<CommandShortcut>
|
||||||
|
<Trans>Page</Trans>
|
||||||
|
</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
navigate(getPagePath($router, "settings", { name: "general" }))
|
navigate(getPagePath($router, "settings", { name: "general" }))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "
|
|||||||
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { $allSystemsById } from "@/lib/stores"
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
import { MaximizeIcon, RefreshCwIcon } from "lucide-react"
|
import { LoaderCircleIcon, MaximizeIcon, RefreshCwIcon, XIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { listenKeys } from "nanostores"
|
import { listenKeys } from "nanostores"
|
||||||
@@ -36,7 +36,7 @@ const syntaxTheme = "github-dark-dimmed"
|
|||||||
|
|
||||||
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
export default function ContainersTable({ systemId }: { systemId?: string }) {
|
||||||
const loadTime = Date.now()
|
const loadTime = Date.now()
|
||||||
const [data, setData] = useState<ContainerRecord[]>([])
|
const [data, setData] = useState<ContainerRecord[] | undefined>(undefined)
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
`sort-c-${systemId ? 1 : 0}`,
|
`sort-c-${systemId ? 1 : 0}`,
|
||||||
[{ id: systemId ? "name" : "system", desc: false }],
|
[{ id: systemId ? "name" : "system", desc: false }],
|
||||||
@@ -54,7 +54,18 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
fields: "id,name,image,cpu,memory,net,health,status,system,updated",
|
||||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||||
})
|
})
|
||||||
.then(({ items }) => items.length && setData((curItems) => {
|
.then(
|
||||||
|
({ items }) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
setData((curItems) => {
|
||||||
|
if (systemId) {
|
||||||
|
return curItems?.filter((item) => item.system !== systemId) ?? []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setData((curItems) => {
|
||||||
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
const lastUpdated = Math.max(items[0].updated, items.at(-1)?.updated ?? 0)
|
||||||
const containerIds = new Set()
|
const containerIds = new Set()
|
||||||
const newItems = []
|
const newItems = []
|
||||||
@@ -64,13 +75,15 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
newItems.push(item)
|
newItems.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const item of curItems) {
|
for (const item of curItems ?? []) {
|
||||||
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
if (!containerIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||||
newItems.push(item)
|
newItems.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newItems
|
return newItems
|
||||||
}))
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial load
|
// initial load
|
||||||
@@ -93,7 +106,7 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data: data ?? [],
|
||||||
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
columns: containerChartCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
@@ -147,16 +160,30 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
|
|||||||
<Trans>Click on a container to view more information.</Trans>
|
<Trans>Click on a container to view more information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
className="ps-4 pe-10 w-full"
|
||||||
/>
|
/>
|
||||||
|
{globalFilter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t`Clear`}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => setGlobalFilter("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md">
|
<div className="rounded-md">
|
||||||
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} />
|
<AllContainersTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -166,10 +193,12 @@ const AllContainersTable = memo(function AllContainersTable({
|
|||||||
table,
|
table,
|
||||||
rows,
|
rows,
|
||||||
colLength,
|
colLength,
|
||||||
|
data,
|
||||||
}: {
|
}: {
|
||||||
table: TableType<ContainerRecord>
|
table: TableType<ContainerRecord>
|
||||||
rows: Row<ContainerRecord>[]
|
rows: Row<ContainerRecord>[]
|
||||||
colLength: number
|
colLength: number
|
||||||
|
data: ContainerRecord[] | undefined
|
||||||
}) {
|
}) {
|
||||||
// The virtualizer will need a reference to the scrollable container element
|
// The virtualizer will need a reference to the scrollable container element
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -213,7 +242,11 @@ const AllContainersTable = memo(function AllContainersTable({
|
|||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||||
|
{data ? (
|
||||||
<Trans>No results.</Trans>
|
<Trans>No results.</Trans>
|
||||||
|
) : (
|
||||||
|
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -491,9 +524,7 @@ function LogsFullscreenDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={onRefresh}
|
||||||
void onRefresh()
|
|
||||||
}}
|
|
||||||
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
className="absolute top-3 right-11 opacity-60 hover:opacity-100 p-1"
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
title={t`Refresh`}
|
title={t`Refresh`}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function LangToggle() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="grid grid-cols-3">
|
<DropdownMenuContent className="grid grid-cols-3">
|
||||||
{languages.map(({ lang, label, e }) => (
|
{languages.map(([lang, label, e]) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={lang}
|
key={lang}
|
||||||
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
|
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ const passwordSchema = v.pipe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const LoginSchema = v.looseObject({
|
const LoginSchema = v.looseObject({
|
||||||
name: honeypot,
|
company_website: honeypot,
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const RegisterSchema = v.looseObject({
|
const RegisterSchema = v.looseObject({
|
||||||
name: honeypot,
|
company_website: honeypot,
|
||||||
email: emailSchema,
|
email: emailSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
@@ -248,8 +248,8 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="name"></label>
|
<label htmlFor="company_website"></label>
|
||||||
<input id="name" type="text" name="name" tabIndex={-1} autoComplete="off" />
|
<input id="company_website" type="text" name="company_website" tabIndex={-1} autoComplete="off" />
|
||||||
</div>
|
</div>
|
||||||
<button className={cn(buttonVariants())} disabled={isLoading}>
|
<button className={cn(buttonVariants())} disabled={isLoading}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
import {
|
import {
|
||||||
ContainerIcon,
|
ContainerIcon,
|
||||||
DatabaseBackupIcon,
|
DatabaseBackupIcon,
|
||||||
|
HardDriveIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
LogsIcon,
|
LogsIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
@@ -29,6 +30,7 @@ import { LangToggle } from "./lang-toggle"
|
|||||||
import { Logo } from "./logo"
|
import { Logo } from "./logo"
|
||||||
import { ModeToggle } from "./mode-toggle"
|
import { ModeToggle } from "./mode-toggle"
|
||||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
|
||||||
const CommandPalette = lazy(() => import("./command-palette"))
|
const CommandPalette = lazy(() => import("./command-palette"))
|
||||||
|
|
||||||
@@ -55,6 +57,13 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
<ContainerIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "smart")}
|
||||||
|
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||||
|
aria-label="S.M.A.R.T."
|
||||||
|
>
|
||||||
|
<HardDriveIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
|||||||
const routes = {
|
const routes = {
|
||||||
home: "/",
|
home: "/",
|
||||||
containers: "/containers",
|
containers: "/containers",
|
||||||
|
smart: "/smart",
|
||||||
system: `/system/:id`,
|
system: `/system/:id`,
|
||||||
settings: `/settings/:name?`,
|
settings: `/settings/:name?`,
|
||||||
forgot_password: `/forgot-password`,
|
forgot_password: `/forgot-password`,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getPaginationRowModel,
|
getPaginationRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
|
type PaginationState,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
@@ -40,7 +41,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { useToast } from "@/components/ui/use-toast"
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
import { alertInfo } from "@/lib/alerts"
|
import { alertInfo } from "@/lib/alerts"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { cn, formatDuration, formatShortDate } from "@/lib/utils"
|
import { cn, formatDuration, formatShortDate, useBrowserStorage } from "@/lib/utils"
|
||||||
import type { AlertsHistoryRecord } from "@/types"
|
import type { AlertsHistoryRecord } from "@/types"
|
||||||
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
import { alertsHistoryColumns } from "../../alerts-history-columns"
|
||||||
|
|
||||||
@@ -67,6 +68,12 @@ export default function AlertsHistoryDataTable() {
|
|||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
const [deleteOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
// Store pagination preference in local storage
|
||||||
|
const [pagination, setPagination] = useBrowserStorage<PaginationState>("ah-pagination", {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
const pbOptions = {
|
const pbOptions = {
|
||||||
@@ -136,12 +143,14 @@ export default function AlertsHistoryDataTable() {
|
|||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onRowSelectionChange: setRowSelection,
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
globalFilter,
|
globalFilter,
|
||||||
|
pagination,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setGlobalFilter,
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
@@ -318,10 +327,10 @@ export default function AlertsHistoryDataTable() {
|
|||||||
<Select
|
<Select
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
table.setPageSize(Number(value))
|
table.setPageSize(Number(value));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[4.8em]" id="rows-per-page">
|
<SelectTrigger className="w-18" id="rows-per-page">
|
||||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top">
|
<SelectContent side="top">
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import Slider from "@/components/ui/slider"
|
||||||
import { HourFormat, Unit } from "@/lib/enums"
|
import { HourFormat, Unit } from "@/lib/enums"
|
||||||
import { dynamicActivate } from "@/lib/i18n"
|
import { dynamicActivate } from "@/lib/i18n"
|
||||||
import languages from "@/lib/languages"
|
import languages from "@/lib/languages"
|
||||||
|
import { $userSettings } from "@/lib/stores"
|
||||||
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
import { chartTimeData, currentHour12 } from "@/lib/utils"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
@@ -17,6 +20,8 @@ import { saveSettings } from "./layout"
|
|||||||
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { i18n } = useLingui()
|
const { i18n } = useLingui()
|
||||||
|
const currentUserSettings = useStore($userSettings)
|
||||||
|
const layoutWidth = currentUserSettings.layoutWidth ?? 1500
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -63,16 +68,37 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{languages.map((lang) => (
|
{languages.map(([lang, label, e]) => (
|
||||||
<SelectItem key={lang.lang} value={lang.lang}>
|
<SelectItem key={lang} value={lang}>
|
||||||
<span className="me-2.5">{lang.e}</span>
|
<span className="me-2.5">{e}</span>
|
||||||
{lang.label}
|
{label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<div className="mb-2">
|
||||||
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
<Trans>Layout width</Trans>
|
||||||
|
</h3>
|
||||||
|
<Label htmlFor="layoutWidth" className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Adjust the width of the main layout</Trans> ({layoutWidth}px)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="layoutWidth"
|
||||||
|
name="layoutWidth"
|
||||||
|
value={[layoutWidth]}
|
||||||
|
onValueChange={(val) => $userSettings.setKey("layoutWidth", val[0])}
|
||||||
|
min={1000}
|
||||||
|
max={2000}
|
||||||
|
step={10}
|
||||||
|
className="w-full mb-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { toast } from "@/components/ui/use-toast"
|
|||||||
import { isAdmin, pb } from "@/lib/api"
|
import { isAdmin, pb } from "@/lib/api"
|
||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
|
import { QuietHours } from "./quiet-hours"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -120,6 +121,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-1 text-lg font-medium">
|
<h3 className="mb-1 text-lg font-medium">
|
||||||
<Trans>Webhook / Push notifications</Trans>
|
<Trans>Webhook / Push notifications</Trans>
|
||||||
@@ -134,6 +136,18 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
{webhooks.length > 0 && (
|
{webhooks.length > 0 && (
|
||||||
<div className="grid gap-2.5" id="webhooks">
|
<div className="grid gap-2.5" id="webhooks">
|
||||||
{webhooks.map((webhook, index) => (
|
{webhooks.map((webhook, index) => (
|
||||||
@@ -146,16 +160,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
<Separator />
|
||||||
variant="outline"
|
<div className="space-y-3">
|
||||||
size="sm"
|
<QuietHours />
|
||||||
className="mt-2 flex items-center gap-1"
|
|
||||||
onClick={addWebhook}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4 -ms-0.5" />
|
|
||||||
<Trans>Add URL</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Button
|
<Button
|
||||||
@@ -194,7 +202,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-muted/40 p-2 md:p-3">
|
<Card className="bg-table-header p-2 md:p-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
|
|||||||
534
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
534
internal/site/src/components/routes/settings/quiet-hours.tsx
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import {
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
ServerIcon,
|
||||||
|
ClockIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ActivityIcon,
|
||||||
|
PenSquareIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { useToast } from "@/components/ui/use-toast"
|
||||||
|
import { pb } from "@/lib/api"
|
||||||
|
import { $systems } from "@/lib/stores"
|
||||||
|
import { formatShortDate } from "@/lib/utils"
|
||||||
|
import type { QuietHoursRecord, SystemRecord } from "@/types"
|
||||||
|
|
||||||
|
const quietHoursTranslation = t`Quiet Hours`
|
||||||
|
|
||||||
|
export function QuietHours() {
|
||||||
|
const [data, setData] = useState<QuietHoursRecord[]>([])
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingRecord, setEditingRecord] = useState<QuietHoursRecord | null>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
const systems = useStore($systems)
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const pbOptions = {
|
||||||
|
expand: "system",
|
||||||
|
fields: "id,user,system,type,start,end,expand.system.name",
|
||||||
|
}
|
||||||
|
// Initial load
|
||||||
|
pb.collection<QuietHoursRecord>("quiet_hours")
|
||||||
|
.getList(0, 200, {
|
||||||
|
...pbOptions,
|
||||||
|
sort: "system",
|
||||||
|
})
|
||||||
|
.then(({ items }) => setData(items))
|
||||||
|
|
||||||
|
// Subscribe to changes
|
||||||
|
;(async () => {
|
||||||
|
unsubscribe = await pb.collection("quiet_hours").subscribe(
|
||||||
|
"*",
|
||||||
|
(e) => {
|
||||||
|
if (e.action === "create") {
|
||||||
|
setData((current) => [e.record as QuietHoursRecord, ...current])
|
||||||
|
}
|
||||||
|
if (e.action === "update") {
|
||||||
|
setData((current) => current.map((r) => (r.id === e.record.id ? (e.record as QuietHoursRecord) : r)))
|
||||||
|
}
|
||||||
|
if (e.action === "delete") {
|
||||||
|
setData((current) => current.filter((r) => r.id !== e.record.id))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pbOptions
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
// Unsubscribe on unmount
|
||||||
|
return () => unsubscribe?.()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await pb.collection("quiet_hours").delete(id)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: (e as Error).message || "Failed to delete quiet hours.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (record: QuietHoursRecord) => {
|
||||||
|
setEditingRecord(record)
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingRecord(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (record: QuietHoursRecord) => {
|
||||||
|
if (record.type === "daily") {
|
||||||
|
// For daily windows, show only time
|
||||||
|
const startTime = new Date(record.start).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
const endTime = new Date(record.end).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
|
||||||
|
return `${startTime} - ${endTime}`
|
||||||
|
}
|
||||||
|
// For one-time windows, show full date and time
|
||||||
|
const start = formatShortDate(record.start)
|
||||||
|
const end = formatShortDate(record.end)
|
||||||
|
return `${start} - ${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getWindowState = (record: QuietHoursRecord): "active" | "past" | "inactive" => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
if (record.type === "daily") {
|
||||||
|
// For daily windows, check if current time is within the window
|
||||||
|
const startDate = new Date(record.start)
|
||||||
|
const endDate = new Date(record.end)
|
||||||
|
|
||||||
|
// Get current time in local timezone
|
||||||
|
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
||||||
|
const startMinutes = startDate.getUTCHours() * 60 + startDate.getUTCMinutes()
|
||||||
|
const endMinutes = endDate.getUTCHours() * 60 + endDate.getUTCMinutes()
|
||||||
|
|
||||||
|
// Convert UTC to local time offset
|
||||||
|
const offset = now.getTimezoneOffset()
|
||||||
|
const localStartMinutes = (startMinutes - offset + 1440) % 1440
|
||||||
|
const localEndMinutes = (endMinutes - offset + 1440) % 1440
|
||||||
|
|
||||||
|
// Handle cases where window spans midnight
|
||||||
|
if (localStartMinutes <= localEndMinutes) {
|
||||||
|
return currentMinutes >= localStartMinutes && currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||||
|
} else {
|
||||||
|
return currentMinutes >= localStartMinutes || currentMinutes < localEndMinutes ? "active" : "inactive"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For one-time windows
|
||||||
|
const startDate = new Date(record.start)
|
||||||
|
const endDate = new Date(record.end)
|
||||||
|
|
||||||
|
if (now >= startDate && now < endDate) {
|
||||||
|
return "active"
|
||||||
|
} else if (now >= endDate) {
|
||||||
|
return "past"
|
||||||
|
} else {
|
||||||
|
return "inactive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:flex items-center justify-between gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-lg font-medium">{quietHoursTranslation}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>
|
||||||
|
Schedule quiet hours where notifications will not be sent, such as during maintenance periods.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-10 shrink-0" onClick={() => setEditingRecord(null)}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
<span className="ms-1">
|
||||||
|
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<QuietHoursDialog editingRecord={editingRecord} systems={systems} onClose={closeDialog} toast={toast} />
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
{data.length > 0 && (
|
||||||
|
<div className="rounded-md border overflow-x-auto whitespace-nowrap">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-border/50">
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ClockIcon className="size-4" />
|
||||||
|
<Trans>Type</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CalendarIcon className="size-4" />
|
||||||
|
<Trans>Schedule</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<ActivityIcon className="size-4" />
|
||||||
|
<Trans>State</Trans>
|
||||||
|
</span>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-4 text-right sr-only">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((record) => (
|
||||||
|
<TableRow key={record.id}>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{record.system ? record.expand?.system?.name || record.system : <Trans>All Systems</Trans>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{record.type === "daily" ? <Trans>Daily</Trans> : <Trans>One-time</Trans>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">{formatDateTime(record)}</TableCell>
|
||||||
|
<TableCell className="px-4 py-3">
|
||||||
|
{(() => {
|
||||||
|
const state = getWindowState(record)
|
||||||
|
const stateConfig = {
|
||||||
|
active: { label: <Trans>Active</Trans>, variant: "success" as const },
|
||||||
|
past: { label: <Trans>Past</Trans>, variant: "danger" as const },
|
||||||
|
inactive: { label: <Trans>Inactive</Trans>, variant: "default" as const },
|
||||||
|
}
|
||||||
|
const config = stateConfig[state]
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-3 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openEditDialog(record)}>
|
||||||
|
<PenSquareIcon className="me-2.5 size-4" />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(record.id)}>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format Date as datetime-local string (YYYY-MM-DDTHH:mm) in local time
|
||||||
|
function formatDateTimeLocal(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0")
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0")
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuietHoursDialog({
|
||||||
|
editingRecord,
|
||||||
|
systems,
|
||||||
|
onClose,
|
||||||
|
toast,
|
||||||
|
}: {
|
||||||
|
editingRecord: QuietHoursRecord | null
|
||||||
|
systems: SystemRecord[]
|
||||||
|
onClose: () => void
|
||||||
|
toast: ReturnType<typeof useToast>["toast"]
|
||||||
|
}) {
|
||||||
|
const [selectedSystem, setSelectedSystem] = useState(editingRecord?.system || "")
|
||||||
|
const [isGlobal, setIsGlobal] = useState(!editingRecord?.system)
|
||||||
|
const [windowType, setWindowType] = useState<"one-time" | "daily">(editingRecord?.type || "one-time")
|
||||||
|
const [startDateTime, setStartDateTime] = useState("")
|
||||||
|
const [endDateTime, setEndDateTime] = useState("")
|
||||||
|
const [startTime, setStartTime] = useState("")
|
||||||
|
const [endTime, setEndTime] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingRecord) {
|
||||||
|
setSelectedSystem(editingRecord.system || "")
|
||||||
|
setIsGlobal(!editingRecord.system)
|
||||||
|
setWindowType(editingRecord.type)
|
||||||
|
if (editingRecord.type === "daily") {
|
||||||
|
// Extract time from datetime
|
||||||
|
const start = new Date(editingRecord.start)
|
||||||
|
const end = editingRecord.end ? new Date(editingRecord.end) : null
|
||||||
|
setStartTime(start.toTimeString().slice(0, 5))
|
||||||
|
setEndTime(end ? end.toTimeString().slice(0, 5) : "")
|
||||||
|
} else {
|
||||||
|
// For one-time, format as datetime-local (local time, not UTC)
|
||||||
|
const startDate = new Date(editingRecord.start)
|
||||||
|
const endDate = editingRecord.end ? new Date(editingRecord.end) : null
|
||||||
|
|
||||||
|
setStartDateTime(formatDateTimeLocal(startDate))
|
||||||
|
setEndDateTime(endDate ? formatDateTimeLocal(endDate) : "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset form with default dates: today at 12pm and 1pm
|
||||||
|
const today = new Date()
|
||||||
|
const noon = new Date(today)
|
||||||
|
noon.setHours(12, 0, 0, 0)
|
||||||
|
const onePm = new Date(today)
|
||||||
|
onePm.setHours(13, 0, 0, 0)
|
||||||
|
|
||||||
|
setSelectedSystem("")
|
||||||
|
setIsGlobal(true)
|
||||||
|
setWindowType("one-time")
|
||||||
|
setStartDateTime(formatDateTimeLocal(noon))
|
||||||
|
setEndDateTime(formatDateTimeLocal(onePm))
|
||||||
|
setStartTime("12:00")
|
||||||
|
setEndTime("13:00")
|
||||||
|
}
|
||||||
|
}, [editingRecord])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let startValue: string
|
||||||
|
let endValue: string | undefined
|
||||||
|
|
||||||
|
if (windowType === "daily") {
|
||||||
|
// For daily windows, convert local time to UTC
|
||||||
|
// Create a date with the time in local timezone, then convert to UTC
|
||||||
|
const startDate = new Date(`2000-01-01T${startTime}:00`)
|
||||||
|
startValue = startDate.toISOString()
|
||||||
|
|
||||||
|
if (endTime) {
|
||||||
|
const endDate = new Date(`2000-01-01T${endTime}:00`)
|
||||||
|
endValue = endDate.toISOString()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For one-time windows, use the datetime values
|
||||||
|
startValue = new Date(startDateTime).toISOString()
|
||||||
|
endValue = endDateTime ? new Date(endDateTime).toISOString() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
user: pb.authStore.record?.id,
|
||||||
|
system: isGlobal ? undefined : selectedSystem,
|
||||||
|
type: windowType,
|
||||||
|
start: startValue,
|
||||||
|
end: endValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingRecord) {
|
||||||
|
await pb.collection("quiet_hours").update(editingRecord.id, data)
|
||||||
|
} else {
|
||||||
|
await pb.collection("quiet_hours").create(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`Failed to save settings`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingRecord ? (
|
||||||
|
<Trans>Edit {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Add {{ foo: quietHoursTranslation }}</Trans>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>Schedule quiet hours where notifications will not be sent.</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Tabs value={isGlobal ? "global" : "system"} onValueChange={(value) => setIsGlobal(value === "global")}>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<Trans>Global</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="system" className="mt-4 space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="system">
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={selectedSystem} onValueChange={setSelectedSystem}>
|
||||||
|
<SelectTrigger id="system">
|
||||||
|
<SelectValue placeholder={t`Select ${{ foo: t`System`.toLocaleLowerCase() }}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{systems.map((system) => (
|
||||||
|
<SelectItem key={system.id} value={system.id}>
|
||||||
|
{system.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{/* Hidden input for native form validation */}
|
||||||
|
<input
|
||||||
|
className="sr-only"
|
||||||
|
type="text"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
value={selectedSystem}
|
||||||
|
onChange={() => {}}
|
||||||
|
required={!isGlobal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="type">
|
||||||
|
<Trans>Type</Trans>
|
||||||
|
</Label>
|
||||||
|
<Select value={windowType} onValueChange={(value: "one-time" | "daily") => setWindowType(value)}>
|
||||||
|
<SelectTrigger id="type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="one-time">
|
||||||
|
<Trans>One-time</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="daily">
|
||||||
|
<Trans>Daily</Trans>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windowType === "one-time" ? (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="start-datetime">
|
||||||
|
<Trans>Start Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="start-datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDateTime}
|
||||||
|
onChange={(e) => setStartDateTime(e.target.value)}
|
||||||
|
min={formatDateTimeLocal(new Date(new Date().setHours(0, 0, 0, 0)))}
|
||||||
|
required
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="end-datetime">
|
||||||
|
<Trans>End Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="end-datetime"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDateTime}
|
||||||
|
onChange={(e) => setEndDateTime(e.target.value)}
|
||||||
|
min={startDateTime || formatDateTimeLocal(new Date())}
|
||||||
|
required
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="start-time">
|
||||||
|
<Trans>Start Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
id="start-time"
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="end-time">
|
||||||
|
<Trans>End Time</Trans>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className="tabular-nums tracking-tighter"
|
||||||
|
id="end-time"
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">{editingRecord ? <Trans>Update</Trans> : <Trans>Create</Trans>}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "@/components/ui/icons"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { toast } from "@/components/ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
@@ -137,21 +138,23 @@ const SectionUniversalToken = memo(() => {
|
|||||||
const [token, setToken] = useState("")
|
const [token, setToken] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [checked, setChecked] = useState(false)
|
const [checked, setChecked] = useState(false)
|
||||||
|
const [isPermanent, setIsPermanent] = useState(false)
|
||||||
|
|
||||||
async function updateToken(enable: number = -1) {
|
async function updateToken(enable: number = -1, permanent: number = -1) {
|
||||||
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
|
// enable: 0 for disable, 1 for enable, -1 (unset) for get current state
|
||||||
const data = await pb.send(`/api/beszel/universal-token`, {
|
const data = await pb.send(`/api/beszel/universal-token`, {
|
||||||
query: {
|
query: {
|
||||||
token,
|
token,
|
||||||
enable,
|
enable,
|
||||||
|
permanent,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setChecked(data.active)
|
setChecked(data.active)
|
||||||
|
setIsPermanent(!!data.permanent)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateToken()
|
updateToken()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -162,20 +165,20 @@ const SectionUniversalToken = memo(() => {
|
|||||||
<Trans>Universal token</Trans>
|
<Trans>Universal token</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
<Trans>When enabled, this token allows agents to self-register without prior system creation.</Trans>
|
||||||
When enabled, this token allows agents to self-register without prior system creation. Expires after one hour
|
|
||||||
or on hub restart.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
</p>
|
||||||
<div className="min-h-16 overflow-auto max-w-full inline-flex items-center gap-5 mt-3 border py-2 ps-5 pe-4 rounded-md">
|
<div className="mt-3 border rounded-md px-4 py-3 max-w-full">
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
<>
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={checked}
|
checked={checked}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
updateToken(checked ? 1 : 0)
|
// Keep current permanence preference when enabling/disabling
|
||||||
|
updateToken(checked ? 1 : 0, isPermanent ? 1 : 0)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="min-w-0 flex-1 overflow-auto">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-primary opacity-60 transition-opacity",
|
"text-sm text-primary opacity-60 transition-opacity",
|
||||||
@@ -184,8 +187,42 @@ const SectionUniversalToken = memo(() => {
|
|||||||
>
|
>
|
||||||
{token}
|
{token}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
<ActionsButtonUniversalToken token={token} checked={checked} />
|
<ActionsButtonUniversalToken token={token} checked={checked} />
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
{checked && (
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
<Trans>Persistence</Trans>
|
||||||
|
</div>
|
||||||
|
<Tabs
|
||||||
|
value={isPermanent ? "permanent" : "ephemeral"}
|
||||||
|
onValueChange={(value) => updateToken(1, value === "permanent" ? 1 : 0)}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="xs:min-w-40" value="ephemeral">
|
||||||
|
<Trans>Ephemeral</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="xs:min-w-40" value="permanent">
|
||||||
|
<Trans>Permanent</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="ephemeral" className="mt-3">
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Expires after one hour or on hub restart.</Trans>
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="permanent" className="mt-3">
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
<Trans>Saved in the database and does not expire until you disable it.</Trans>
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
internal/site/src/components/routes/smart.tsx
Normal file
20
internal/site/src/components/routes/smart.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import SmartTable from "@/components/routes/system/smart-table"
|
||||||
|
import { ActiveAlerts } from "@/components/active-alerts"
|
||||||
|
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||||
|
|
||||||
|
export default function Smart() {
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `S.M.A.R.T. / Beszel`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ActiveAlerts />
|
||||||
|
<SmartTable />
|
||||||
|
</div>
|
||||||
|
<FooterRepoLink />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,15 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro"
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import {
|
import { XIcon } from "lucide-react"
|
||||||
ChevronRightSquareIcon,
|
|
||||||
ClockArrowUp,
|
|
||||||
CpuIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
LayoutGridIcon,
|
|
||||||
MonitorIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { subscribeKeys } from "nanostores"
|
import { subscribeKeys } from "nanostores"
|
||||||
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { type JSX, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
|
||||||
@@ -24,7 +16,7 @@ import MemChart from "@/components/charts/mem-chart"
|
|||||||
import SwapChart from "@/components/charts/swap-chart"
|
import SwapChart from "@/components/charts/swap-chart"
|
||||||
import TemperatureChart from "@/components/charts/temperature-chart"
|
import TemperatureChart from "@/components/charts/temperature-chart"
|
||||||
import { getPbTimestamp, pb } from "@/lib/api"
|
import { getPbTimestamp, pb } from "@/lib/api"
|
||||||
import { ChartType, ConnectionType, connectionTypeLabels, Os, SystemStatus, Unit } from "@/lib/enums"
|
import { ChartType, SystemStatus, Unit } from "@/lib/enums"
|
||||||
import { batteryStateTranslations } from "@/lib/i18n"
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import {
|
import {
|
||||||
$allSystemsById,
|
$allSystemsById,
|
||||||
@@ -44,8 +36,6 @@ import {
|
|||||||
compareSemVer,
|
compareSemVer,
|
||||||
decimalString,
|
decimalString,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
secondsToString,
|
|
||||||
getHostDisplayValue,
|
|
||||||
listen,
|
listen,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
@@ -56,24 +46,24 @@ import type {
|
|||||||
ChartTimes,
|
ChartTimes,
|
||||||
ContainerStatsRecord,
|
ContainerStatsRecord,
|
||||||
GPUData,
|
GPUData,
|
||||||
|
SystemDetailsRecord,
|
||||||
SystemInfo,
|
SystemInfo,
|
||||||
SystemRecord,
|
SystemRecord,
|
||||||
SystemStats,
|
SystemStats,
|
||||||
SystemStatsRecord,
|
SystemStatsRecord,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import ChartTimeSelect from "../charts/chart-time-select"
|
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import Spinner from "../spinner"
|
import Spinner from "../spinner"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { AppleIcon, ChartAverage, ChartMax, FreeBsdIcon, Rows, TuxIcon, WebSocketIcon, WindowsIcon } from "../ui/icons"
|
import { ChartAverage, ChartMax } from "../ui/icons"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
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 NetworkSheet from "./system/network-sheet"
|
||||||
import CpuCoresSheet from "./system/cpu-sheet"
|
import CpuCoresSheet from "./system/cpu-sheet"
|
||||||
import LineChartDefault from "../charts/line-chart"
|
import LineChartDefault from "../charts/line-chart"
|
||||||
|
import { pinnedAxisDomain } from "../ui/chart"
|
||||||
|
import InfoBar from "./system/info-bar"
|
||||||
|
|
||||||
type ChartTimeData = {
|
type ChartTimeData = {
|
||||||
time: number
|
time: number
|
||||||
@@ -153,8 +143,8 @@ async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function dockerOrPodman(str: string, system: SystemRecord): string {
|
function dockerOrPodman(str: string, isPodman: boolean): string {
|
||||||
if (system.info.p) {
|
if (isPodman) {
|
||||||
return str.replace("docker", "podman").replace("Docker", "Podman")
|
return str.replace("docker", "podman").replace("Docker", "Podman")
|
||||||
}
|
}
|
||||||
return str
|
return str
|
||||||
@@ -177,6 +167,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
|
const isLongerChart = !["1m", "1h"].includes(chartTime) // true if chart time is not 1m or 1h
|
||||||
const userSettings = $userSettings.get()
|
const userSettings = $userSettings.get()
|
||||||
const chartWrapRef = useRef<HTMLDivElement>(null)
|
const chartWrapRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -186,6 +177,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
persistChartTime.current = false
|
persistChartTime.current = false
|
||||||
setSystemStats([])
|
setSystemStats([])
|
||||||
setContainerData([])
|
setContainerData([])
|
||||||
|
setDetails({} as SystemDetailsRecord)
|
||||||
$containerFilter.set("")
|
$containerFilter.set("")
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -213,8 +205,23 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
}, [system?.info?.v])
|
}, [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
|
// subscribe to realtime metrics if chart time is 1m
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsub = () => {}
|
let unsub = () => {}
|
||||||
if (!system.id || chartTime !== "1m") {
|
if (!system.id || chartTime !== "1m") {
|
||||||
@@ -252,7 +259,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}
|
}
|
||||||
}, [chartTime, system.id])
|
}, [chartTime, system.id])
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
const chartData: ChartData = useMemo(() => {
|
const chartData: ChartData = useMemo(() => {
|
||||||
const lastCreated = Math.max(
|
const lastCreated = Math.max(
|
||||||
(systemStats.at(-1)?.created as number) ?? 0,
|
(systemStats.at(-1)?.created as number) ?? 0,
|
||||||
@@ -292,7 +298,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// get stats
|
// get stats
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!system.id || !chartTime || chartTime === "1m") {
|
if (!system.id || !chartTime || chartTime === "1m") {
|
||||||
return
|
return
|
||||||
@@ -332,63 +337,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
})
|
})
|
||||||
}, [system, chartTime])
|
}, [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 */
|
/** Space for tooltip if more than 10 sensors and no containers table */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
const sensors = Object.keys(systemStats.at(-1)?.stats.t ?? {})
|
||||||
@@ -452,118 +400,42 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
||||||
|
|
||||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||||
const lastGpuVals = Object.values(systemStats.at(-1)?.stats.g ?? {})
|
const lastGpus = systemStats.at(-1)?.stats?.g
|
||||||
const hasGpuData = lastGpuVals.length > 0
|
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined || gpu.pp !== undefined)
|
|
||||||
const hasGpuEnginesData = lastGpuVals.some((gpu) => gpu.e !== undefined)
|
|
||||||
|
|
||||||
let translatedStatus: string = system.status
|
let hasGpuData = false
|
||||||
if (system.status === SystemStatus.Up) {
|
let hasGpuEnginesData = false
|
||||||
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
|
let hasGpuPowerData = false
|
||||||
} else if (system.status === SystemStatus.Down) {
|
|
||||||
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
if (lastGpus) {
|
||||||
|
// check if there are any GPUs with engines
|
||||||
|
for (const id in lastGpus) {
|
||||||
|
hasGpuData = true
|
||||||
|
if (lastGpus[id].e !== undefined) {
|
||||||
|
hasGpuEnginesData = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// check if there are any GPUs with power data
|
||||||
|
for (let i = 0; i < systemStats.length && !hasGpuPowerData; i++) {
|
||||||
|
const gpus = systemStats[i].stats?.g
|
||||||
|
if (!gpus) continue
|
||||||
|
for (const id in gpus) {
|
||||||
|
if (gpus[id].p !== undefined || gpus[id].pp !== undefined) {
|
||||||
|
hasGpuPowerData = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLinux = !(details?.os ?? system.info?.os)
|
||||||
|
const isPodman = details?.podman ?? system.info?.p ?? false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
|
<div ref={chartWrapRef} className="grid gap-4 mb-14 overflow-x-clip">
|
||||||
{/* system info */}
|
{/* system info */}
|
||||||
<Card>
|
<InfoBar system={system} chartData={chartData} grid={grid} setGrid={setGrid} details={details} />
|
||||||
<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">
|
{/* <Tabs defaultValue="overview" className="w-full">
|
||||||
<TabsList className="w-full h-11">
|
<TabsList className="w-full h-11">
|
||||||
@@ -575,7 +447,6 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs> */}
|
</Tabs> */}
|
||||||
|
|
||||||
|
|
||||||
{/* main charts */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -603,6 +474,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
]}
|
]}
|
||||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||||
|
domain={pinnedAxisDomain()}
|
||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
@@ -610,7 +482,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={dockerOrPodman(t`Docker CPU Usage`, system)}
|
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
|
||||||
description={t`Average CPU utilization of containers`}
|
description={t`Average CPU utilization of containers`}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
@@ -637,8 +509,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={dockerOrPodman(t`Docker Memory Usage`, system)}
|
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
|
||||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart
|
<ContainerChart
|
||||||
@@ -758,8 +630,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
title={dockerOrPodman(t`Docker Network I/O`, system)}
|
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
|
||||||
description={dockerOrPodman(t`Network traffic of docker containers`, system)}
|
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart
|
<ContainerChart
|
||||||
@@ -798,10 +670,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
|
|
||||||
{/* Temperature chart */}
|
{/* Temperature chart */}
|
||||||
{systemStats.at(-1)?.stats.t && (
|
{systemStats.at(-1)?.stats.t && (
|
||||||
<div
|
<div ref={temperatureChartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
|
||||||
ref={temperatureChartRef}
|
|
||||||
className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}
|
|
||||||
>
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
empty={dataEmpty}
|
empty={dataEmpty}
|
||||||
grid={grid}
|
grid={grid}
|
||||||
@@ -870,8 +739,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<GpuEnginesChart chartData={chartData} />
|
<GpuEnginesChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
|
{lastGpus &&
|
||||||
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
|
Object.keys(lastGpus).map((id) => {
|
||||||
|
const gpu = lastGpus[id] as GPUData
|
||||||
return (
|
return (
|
||||||
<div key={id} className="contents">
|
<div key={id} className="contents">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -963,7 +833,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
label: t`Write`,
|
label: t`Write`,
|
||||||
dataKey: ({ stats }) => {
|
dataKey: ({ stats }) => {
|
||||||
if (showMax) {
|
if (showMax) {
|
||||||
return stats?.efs?.[extraFsName]?.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
|
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
||||||
},
|
},
|
||||||
@@ -1001,15 +873,13 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && (
|
{compareSemVer(chartData.agentVersion, parseSemVer("0.15.0")) >= 0 && <LazySmartTable systemId={system.id} />}
|
||||||
<LazySmartTable systemId={system.id} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
{containerData.length > 0 && compareSemVer(chartData.agentVersion, parseSemVer("0.14.0")) >= 0 && (
|
||||||
<LazyContainersTable systemId={system.id} />
|
<LazyContainersTable systemId={system.id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{system.info?.os === Os.Linux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
{isLinux && compareSemVer(chartData.agentVersion, parseSemVer("0.16.0")) >= 0 && (
|
||||||
<LazySystemdTable systemId={system.id} />
|
<LazySystemdTable systemId={system.id} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1059,13 +929,10 @@ function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilt
|
|||||||
return () => clearTimeout(handle)
|
return () => clearTimeout(handle)
|
||||||
}, [inputValue, storeValue, store])
|
}, [inputValue, storeValue, store])
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value
|
const value = e.target.value
|
||||||
setInputValue(value)
|
setInputValue(value)
|
||||||
},
|
}, [])
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
setInputValue("")
|
setInputValue("")
|
||||||
|
|||||||
229
internal/site/src/components/routes/system/info-bar.tsx
Normal file
229
internal/site/src/components/routes/system/info-bar.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,37 +1,64 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
type ColumnDef,
|
||||||
ColumnFiltersState,
|
type ColumnFiltersState,
|
||||||
Column,
|
type Column,
|
||||||
|
type SortingState,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
SortingState,
|
|
||||||
useReactTable,
|
useReactTable,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { Activity, Box, Clock, HardDrive, HashIcon, CpuIcon, BinaryIcon, RotateCwIcon, LoaderCircleIcon, CheckCircle2Icon, XCircleIcon, ArrowLeftRightIcon } from "lucide-react"
|
import {
|
||||||
|
Activity,
|
||||||
|
Box,
|
||||||
|
Clock,
|
||||||
|
HardDrive,
|
||||||
|
BinaryIcon,
|
||||||
|
RotateCwIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
CheckCircle2Icon,
|
||||||
|
XCircleIcon,
|
||||||
|
ArrowLeftRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
ServerIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { SmartData, SmartAttribute } from "@/types"
|
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||||
import { formatBytes, toFixedFloat, formatTemperature, cn, secondsToString } from "@/lib/utils"
|
import {
|
||||||
|
formatBytes,
|
||||||
|
toFixedFloat,
|
||||||
|
formatTemperature,
|
||||||
|
cn,
|
||||||
|
secondsToString,
|
||||||
|
hourWithSeconds,
|
||||||
|
formatShortDate,
|
||||||
|
} from "@/lib/utils"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { $allSystemsById } from "@/lib/stores"
|
||||||
import { ThermometerIcon } from "@/components/ui/icons"
|
import { ThermometerIcon } from "@/components/ui/icons"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { useCallback, useMemo, useEffect, useState } from "react"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
// Column definition for S.M.A.R.T. attributes table
|
// Column definition for S.M.A.R.T. attributes table
|
||||||
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||||
@@ -66,106 +93,71 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type DiskInfo = {
|
|
||||||
device: string
|
|
||||||
model: string
|
|
||||||
serialNumber: string
|
|
||||||
firmwareVersion: string
|
|
||||||
capacity: string
|
|
||||||
status: string
|
|
||||||
temperature: number
|
|
||||||
deviceType: string
|
|
||||||
powerOnHours?: number
|
|
||||||
powerCycles?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to format capacity display
|
// Function to format capacity display
|
||||||
function formatCapacity(bytes: number): string {
|
function formatCapacity(bytes: number): string {
|
||||||
const { value, unit } = formatBytes(bytes)
|
const { value, unit } = formatBytes(bytes)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to convert SmartData to DiskInfo
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
|
|
||||||
const unknown = "Unknown"
|
|
||||||
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
|
|
||||||
device: smartData.dn || key,
|
|
||||||
model: smartData.mn || unknown,
|
|
||||||
serialNumber: smartData.sn || unknown,
|
|
||||||
firmwareVersion: smartData.fv || unknown,
|
|
||||||
capacity: smartData.c ? formatCapacity(smartData.c) : unknown,
|
|
||||||
status: smartData.s || unknown,
|
|
||||||
temperature: smartData.t || 0,
|
|
||||||
deviceType: smartData.dt || unknown,
|
|
||||||
// These fields need to be extracted from SmartAttribute if available
|
|
||||||
powerOnHours: smartData.a?.find(attr => {
|
|
||||||
const name = attr.n.toLowerCase();
|
|
||||||
return name.includes("poweronhours") || name.includes("power_on_hours");
|
|
||||||
})?.rv,
|
|
||||||
powerCycles: smartData.a?.find(attr => {
|
|
||||||
const name = attr.n.toLowerCase();
|
|
||||||
return (name.includes("power") && name.includes("cycle")) || name.includes("startstopcycles");
|
|
||||||
})?.rv,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
||||||
export const columns: ColumnDef<DiskInfo>[] = [
|
|
||||||
{
|
{
|
||||||
accessorKey: "device",
|
id: "system",
|
||||||
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
|
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-30 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||||
cell: ({ row }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="font-medium ms-1.5">{row.getValue("device")}</div>
|
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
|
||||||
|
{getValue() as string}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "model",
|
accessorKey: "model",
|
||||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
||||||
cell: ({ row }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="max-w-50 truncate ms-1.5" title={row.getValue("model")}>
|
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
|
||||||
{row.getValue("model")}
|
{getValue() as string}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "capacity",
|
accessorKey: "capacity",
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "temperature",
|
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
|
||||||
const { value, unit } = formatTemperature(getValue() as number)
|
|
||||||
return <span className="ms-1.5">{`${value} ${unit}`}</span>
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "status",
|
accessorKey: "state",
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const status = getValue() as string
|
const status = getValue() as string
|
||||||
return (
|
return (
|
||||||
<div className="ms-1.5">
|
<div className="ms-1.5">
|
||||||
<Badge
|
<Badge variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}>{status}</Badge>
|
||||||
variant={status === "PASSED" ? "success" : status === "FAILED" ? "danger" : "warning"}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "deviceType",
|
accessorKey: "type",
|
||||||
sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
|
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="ms-1.5">
|
<div className="ms-1.5">
|
||||||
@@ -176,17 +168,15 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "powerOnHours",
|
accessorKey: "hours",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />,
|
header: ({ column }) => (
|
||||||
|
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
||||||
|
),
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const hours = (getValue() ?? 0) as number
|
const hours = (getValue() ?? 0) as number
|
||||||
if (!hours && hours !== 0) {
|
if (!hours && hours !== 0) {
|
||||||
return (
|
return <div className="text-sm text-muted-foreground ms-1.5">N/A</div>
|
||||||
<div className="text-sm text-muted-foreground ms-1.5">
|
|
||||||
N/A
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
const seconds = hours * 3600
|
const seconds = hours * 3600
|
||||||
return (
|
return (
|
||||||
@@ -198,44 +188,71 @@ export const columns: ColumnDef<DiskInfo>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "powerCycles",
|
accessorKey: "cycles",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />,
|
header: ({ column }) => (
|
||||||
|
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
||||||
|
),
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const cycles = getValue() as number | undefined
|
const cycles = getValue() as number | undefined
|
||||||
if (!cycles && cycles !== 0) {
|
if (!cycles && cycles !== 0) {
|
||||||
return (
|
return <div className="text-muted-foreground ms-1.5">N/A</div>
|
||||||
<div className="text-muted-foreground ms-1.5">
|
|
||||||
N/A
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return <span className="ms-1.5">{cycles}</span>
|
return <span className="ms-1.5">{cycles.toLocaleString()}</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "serialNumber",
|
accessorKey: "temp",
|
||||||
sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => {
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
const { value, unit } = formatTemperature(getValue() as number)
|
||||||
),
|
return <span className="ms-1.5">{`${value} ${unit}`}</span>
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// accessorKey: "serial",
|
||||||
|
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
|
||||||
|
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||||
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// accessorKey: "firmware",
|
||||||
|
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
|
||||||
|
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||||
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
accessorKey: "firmwareVersion",
|
id: "updated",
|
||||||
sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
accessorFn: (record) => record.updated,
|
||||||
cell: ({ getValue }) => (
|
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={Clock} />,
|
||||||
<span className="ms-1.5">{getValue() as string}</span>
|
cell: ({ getValue }) => {
|
||||||
),
|
const timestamp = getValue() as string
|
||||||
|
// if today, use hourWithSeconds, otherwise use formatShortDate
|
||||||
|
const formatter =
|
||||||
|
new Date(timestamp).toDateString() === new Date().toDateString() ? hourWithSeconds : formatShortDate
|
||||||
|
return <span className="ms-1.5 tabular-nums">{formatter(timestamp)}</span>
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
|
function HeaderButton({
|
||||||
|
column,
|
||||||
|
name,
|
||||||
|
Icon,
|
||||||
|
}: {
|
||||||
|
column: Column<SmartDeviceRecord>
|
||||||
|
name: string
|
||||||
|
Icon: React.ElementType
|
||||||
|
}) {
|
||||||
const isSorted = column.getIsSorted()
|
const isSorted = column.getIsSorted()
|
||||||
return (
|
return (
|
||||||
<Button
|
<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")}
|
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"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
>
|
>
|
||||||
@@ -245,39 +262,187 @@ function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DisksTable({ systemId }: { systemId: string }) {
|
export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||||
const [sorting, setSorting] = React.useState<SortingState>([{ id: "device", desc: false }])
|
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [rowSelection, setRowSelection] = React.useState({})
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
const [smartData, setSmartData] = React.useState<Record<string, SmartData> | undefined>(undefined)
|
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
||||||
const [activeDisk, setActiveDisk] = React.useState<DiskInfo | null>(null)
|
const [activeDiskId, setActiveDiskId] = useState<string | null>(null)
|
||||||
const [sheetOpen, setSheetOpen] = React.useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
|
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
||||||
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
const openSheet = (disk: DiskInfo) => {
|
const openSheet = (disk: SmartDeviceRecord) => {
|
||||||
setActiveDisk(disk)
|
setActiveDiskId(disk.id)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch smart data when component mounts or systemId changes
|
// Fetch smart devices
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (systemId) {
|
const controller = new AbortController()
|
||||||
pb.send<Record<string, SmartData>>("/api/beszel/smart", { query: { system: systemId } })
|
|
||||||
.then((data) => {
|
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||||
setSmartData(data)
|
.getFullList({
|
||||||
|
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
||||||
|
fields: SMART_DEVICE_FIELDS,
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.catch(() => setSmartData({}))
|
.then(setSmartDevices)
|
||||||
|
.catch((err) => {
|
||||||
|
if (!err.isAbort) {
|
||||||
|
setSmartDevices([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [systemId])
|
||||||
|
|
||||||
|
// Subscribe to updates
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | undefined
|
||||||
|
const pbOptions = systemId
|
||||||
|
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||||
|
: { fields: SMART_DEVICE_FIELDS }
|
||||||
|
|
||||||
|
; (async () => {
|
||||||
|
try {
|
||||||
|
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||||
|
"*",
|
||||||
|
(event) => {
|
||||||
|
const record = event.record as SmartDeviceRecord
|
||||||
|
setSmartDevices((currentDevices) => {
|
||||||
|
const devices = currentDevices ?? []
|
||||||
|
const matchesSystemScope = !systemId || record.system === systemId
|
||||||
|
|
||||||
|
if (event.action === "delete") {
|
||||||
|
return devices.filter((device) => device.id !== record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchesSystemScope) {
|
||||||
|
// Record moved out of scope; ensure it disappears locally.
|
||||||
|
return devices.filter((device) => device.id !== record.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||||
|
if (existingIndex === -1) {
|
||||||
|
return [record, ...devices]
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...devices]
|
||||||
|
next[existingIndex] = record
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
},
|
||||||
|
pbOptions
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to subscribe to SMART device updates:", error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
// Convert SmartData to DiskInfo, if no data use empty array
|
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
|
||||||
const diskData = React.useMemo(() => {
|
if (!disk.system) return
|
||||||
return smartData ? convertSmartDataToDiskInfo(smartData) : []
|
setRowActionState({ type: "refresh", id: disk.id })
|
||||||
}, [smartData])
|
try {
|
||||||
|
await pb.send("/api/beszel/smart/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
query: { system: disk.system },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh SMART device:", error)
|
||||||
|
} finally {
|
||||||
|
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
|
||||||
|
setRowActionState({ type: "delete", id: disk.id })
|
||||||
|
try {
|
||||||
|
await pb.collection("smart_devices").delete(disk.id)
|
||||||
|
// setSmartDevices((current) => current?.filter((device) => device.id !== disk.id))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete SMART device:", error)
|
||||||
|
} finally {
|
||||||
|
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
|
||||||
|
() => ({
|
||||||
|
id: "actions",
|
||||||
|
enableSorting: false,
|
||||||
|
header: () => (
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Actions</Trans>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const disk = row.original
|
||||||
|
const isRowRefreshing = rowActionState?.id === disk.id && rowActionState.type === "refresh"
|
||||||
|
const isRowDeleting = rowActionState?.id === disk.id && rowActionState.type === "delete"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-10"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Open menu</Trans>
|
||||||
|
</span>
|
||||||
|
<MoreHorizontalIcon className="w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleRowRefresh(disk)
|
||||||
|
}}
|
||||||
|
disabled={isRowRefreshing || isRowDeleting}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className={cn("me-2.5 size-4", isRowRefreshing && "animate-spin")} />
|
||||||
|
<Trans>Refresh</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleDeleteDevice(disk)
|
||||||
|
}}
|
||||||
|
disabled={isRowDeleting}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="me-2.5 size-4" />
|
||||||
|
<Trans>Delete</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[handleRowRefresh, handleDeleteDevice, rowActionState]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter columns based on whether systemId is provided
|
||||||
|
const tableColumns = useMemo(() => {
|
||||||
|
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
|
return [...baseColumns, actionColumn]
|
||||||
|
}, [systemId, actionColumn])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: diskData,
|
data: smartDevices || ([] as SmartDeviceRecord[]),
|
||||||
columns: columns,
|
columns: tableColumns,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -288,10 +453,26 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
|||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
rowSelection,
|
rowSelection,
|
||||||
|
globalFilter,
|
||||||
|
},
|
||||||
|
onGlobalFilterChange: setGlobalFilter,
|
||||||
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
|
const disk = row.original
|
||||||
|
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
||||||
|
const device = disk.name ?? ""
|
||||||
|
const model = disk.model ?? ""
|
||||||
|
const status = disk.state ?? ""
|
||||||
|
const type = disk.type ?? ""
|
||||||
|
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
||||||
|
return (filterValue as string)
|
||||||
|
.toLowerCase()
|
||||||
|
.split(" ")
|
||||||
|
.every((term) => searchString.includes(term))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!diskData.length && !columnFilters.length) {
|
// Hide the table on system pages if there's no data, but always show on global page
|
||||||
|
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,21 +482,31 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
|||||||
<CardHeader className="p-0 mb-4">
|
<CardHeader className="p-0 mb-4">
|
||||||
<div className="grid md:flex gap-5 w-full items-end">
|
<div className="grid md:flex gap-5 w-full items-end">
|
||||||
<div className="px-2 sm:px-1">
|
<div className="px-2 sm:px-1">
|
||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">S.M.A.R.T.</CardTitle>
|
||||||
S.M.A.R.T.
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="flex">
|
<CardDescription className="flex">
|
||||||
<Trans>Click on a device to view more information.</Trans>
|
<Trans>Click on a device to view more information.</Trans>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
value={globalFilter}
|
||||||
onChange={(event) =>
|
onChange={(event) => setGlobalFilter(event.target.value)}
|
||||||
table.getColumn("device")?.setFilterValue(event.target.value)
|
className="px-4 w-full max-w-full md:w-64"
|
||||||
}
|
|
||||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
|
||||||
/>
|
/>
|
||||||
|
{globalFilter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t`Clear`}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => setGlobalFilter("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div className="rounded-md border text-nowrap">
|
<div className="rounded-md border text-nowrap">
|
||||||
@@ -326,12 +517,7 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
|||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header) => {
|
||||||
return (
|
return (
|
||||||
<TableHead key={header.id} className="px-2">
|
<TableHead key={header.id} className="px-2">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -349,22 +535,19 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
|||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id} className="md:ps-5">
|
<TableCell key={cell.id} className="md:ps-5">
|
||||||
{flexRender(
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell colSpan={tableColumns.length} className="h-24 text-center">
|
||||||
colSpan={columns.length}
|
{smartDevices ? (
|
||||||
className="h-24 text-center"
|
t`No results.`
|
||||||
>
|
) : (
|
||||||
{smartData ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
|
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -372,28 +555,53 @@ export default function DisksTable({ systemId }: { systemId: string }) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<DiskSheet disk={activeDisk} smartData={smartData?.[activeDisk?.serialNumber ?? ""]} open={sheetOpen} onOpenChange={setSheetOpen} />
|
<DiskSheet diskId={activeDiskId} open={sheetOpen} onOpenChange={setSheetOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | null; smartData?: SmartData; open: boolean; onOpenChange: (open: boolean) => void }) {
|
function DiskSheet({
|
||||||
if (!disk) return null
|
diskId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
diskId: string | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [disk, setDisk] = useState<SmartDeviceRecord | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const smartAttributes = smartData?.a || []
|
// Fetch full device record (including attributes) when sheet opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!diskId) {
|
||||||
|
setDisk(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only fetch when opening, not when closing (keeps data visible during close animation)
|
||||||
|
if (!open) return
|
||||||
|
setIsLoading(true)
|
||||||
|
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||||
|
.getOne(diskId)
|
||||||
|
.then(setDisk)
|
||||||
|
.catch(() => setDisk(null))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [open, diskId])
|
||||||
|
|
||||||
|
const smartAttributes = disk?.attributes || []
|
||||||
|
|
||||||
// Find all attributes where when failed is not empty
|
// Find all attributes where when failed is not empty
|
||||||
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '')
|
const failedAttributes = smartAttributes.filter((attr) => attr.wf && attr.wf.trim() !== "")
|
||||||
|
|
||||||
// Filter columns to only show those that have values in at least one row
|
// Filter columns to only show those that have values in at least one row
|
||||||
const visibleColumns = React.useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
return smartColumns.filter(column => {
|
return smartColumns.filter((column) => {
|
||||||
const accessorKey = (column as any).accessorKey as keyof SmartAttribute
|
const accessorKey = "accessorKey" in column ? (column.accessorKey as keyof SmartAttribute | undefined) : undefined
|
||||||
if (!accessorKey) {
|
if (!accessorKey) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check if any row has a non-empty value for this column
|
// Check if any row has a non-empty value for this column
|
||||||
return smartAttributes.some(attr => {
|
return smartAttributes.some((attr) => {
|
||||||
return attr[accessorKey] !== undefined
|
return attr[accessorKey] !== undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -405,27 +613,60 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unknown = "Unknown"
|
||||||
|
const deviceName = disk?.name || unknown
|
||||||
|
const model = disk?.model || unknown
|
||||||
|
const capacity = disk?.capacity ? formatCapacity(disk.capacity) : unknown
|
||||||
|
const serialNumber = disk?.serial || unknown
|
||||||
|
const firmwareVersion = disk?.firmware || unknown
|
||||||
|
const status = disk?.state || unknown
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="w-full sm:max-w-220 gap-0">
|
<SheetContent className="w-full sm:max-w-220 gap-0">
|
||||||
<SheetHeader className="mb-0 border-b">
|
<SheetHeader className="mb-0 border-b">
|
||||||
<SheetTitle><Trans>S.M.A.R.T. Details</Trans> - {disk.device}</SheetTitle>
|
<SheetTitle>
|
||||||
|
<Trans>S.M.A.R.T. Details</Trans> - {deviceName}
|
||||||
|
</SheetTitle>
|
||||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
{disk.model} <Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
{model}
|
||||||
{disk.serialNumber}
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
{capacity}
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>{serialNumber}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<Trans>Serial Number</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span>{firmwareVersion}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<Trans>Firmware</Trans>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
|
<div className="flex-1 overflow-auto p-4 flex flex-col gap-4">
|
||||||
<Alert className="pb-3">
|
{isLoading ? (
|
||||||
{smartData?.s === "PASSED" ? (
|
<div className="flex justify-center py-8">
|
||||||
<CheckCircle2Icon className="size-4" />
|
<LoaderCircleIcon className="animate-spin size-10 opacity-60" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<XCircleIcon className="size-4" />
|
<>
|
||||||
)}
|
<Alert className="pb-3">
|
||||||
<AlertTitle><Trans>S.M.A.R.T. Self-Test</Trans>: {smartData?.s}</AlertTitle>
|
{status === "PASSED" ? <CheckCircle2Icon className="size-4" /> : <XCircleIcon className="size-4" />}
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>S.M.A.R.T. Self-Test</Trans>: {status}
|
||||||
|
</AlertTitle>
|
||||||
{failedAttributes.length > 0 && (
|
{failedAttributes.length > 0 && (
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>Failed Attributes:</Trans> {failedAttributes.map(attr => attr.n).join(", ")}
|
<Trans>Failed Attributes:</Trans> {failedAttributes.map((attr) => attr.n).join(", ")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -439,10 +680,7 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
|
|||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -451,23 +689,17 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
// Check if the attribute is failed
|
// Check if the attribute is failed
|
||||||
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
|
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={row.id} className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}>
|
||||||
key={row.id}
|
|
||||||
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -477,6 +709,8 @@ function DiskSheet({ disk, smartData, open, onOpenChange }: { disk: DiskInfo | n
|
|||||||
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
|
<Trans>No S.M.A.R.T. attributes available for this device.</Trans>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ function SystemdSheet({
|
|||||||
setDetails(details)
|
setDetails(details)
|
||||||
} else {
|
} else {
|
||||||
setDetails(null)
|
setDetails(null)
|
||||||
setError(t`No systemd details returned`)
|
setError(t`No results found.`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -362,12 +362,12 @@ function SystemdSheet({
|
|||||||
{hasCurrent ? current : notAvailable}
|
{hasCurrent ? current : notAvailable}
|
||||||
{hasMax && (
|
{hasMax && (
|
||||||
<span className="text-muted-foreground ms-1.5">
|
<span className="text-muted-foreground ms-1.5">
|
||||||
{t`(limit: ${max})`}
|
{`(${t`limit`}: ${max})`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{max === null && (
|
{max === null && (
|
||||||
<span className="text-muted-foreground ms-1.5">
|
<span className="text-muted-foreground ms-1.5">
|
||||||
{t`(limit: unlimited)`}
|
{`(${t`limit`}: ${t`Unlimited`.toLowerCase()})`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -436,6 +436,8 @@ function SystemdSheet({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const capitalize = (str: string) => `${str.charAt(0).toUpperCase()}${str.slice(1).toLowerCase()}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
<SheetContent className="w-full sm:max-w-220 p-6 overflow-y-auto">
|
||||||
@@ -466,10 +468,10 @@ function SystemdSheet({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{renderRow("name", t`Name`, service.name, true)}
|
{renderRow("name", t`Name`, service.name, true)}
|
||||||
{renderRow("description", t`Description`, details?.Description, true)}
|
{renderRow("description", t`Description`, details?.Description, true)}
|
||||||
{renderRow("loadState", t`Load State`, details?.LoadState, true)}
|
{renderRow("loadState", t`Load state`, details?.LoadState, true)}
|
||||||
{renderRow(
|
{renderRow(
|
||||||
"bootState",
|
"bootState",
|
||||||
t`Boot State`,
|
t`Boot state`,
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{details?.UnitFileState}
|
{details?.UnitFileState}
|
||||||
{details?.UnitFilePreset && (
|
{details?.UnitFilePreset && (
|
||||||
@@ -478,8 +480,8 @@ function SystemdSheet({
|
|||||||
</div>,
|
</div>,
|
||||||
true
|
true
|
||||||
)}
|
)}
|
||||||
{renderRow("unitFile", t`Unit File`, details?.FragmentPath, true)}
|
{renderRow("unitFile", t`Unit file`, details?.FragmentPath, true)}
|
||||||
{renderRow("active", t`Active State`, activeStateValue, true)}
|
{renderRow("active", t`Active state`, activeStateValue, true)}
|
||||||
{renderRow("status", t`Status`, statusTextValue, true)}
|
{renderRow("status", t`Status`, statusTextValue, true)}
|
||||||
{renderRow(
|
{renderRow(
|
||||||
"documentation",
|
"documentation",
|
||||||
@@ -501,12 +503,12 @@ function SystemdSheet({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
|
{renderRow("mainPid", t`Main PID`, mainPidValue, true)}
|
||||||
{renderRow("execMainPid", t`Exec Main PID`, execMainPidValue)}
|
{renderRow("execMainPid", t`Exec main PID`, execMainPidValue)}
|
||||||
{renderRow("tasks", t`Tasks`, tasks, true)}
|
{renderRow("tasks", t`Tasks`, tasks, true)}
|
||||||
{renderRow("cpuTime", t`CPU Time`, cpuTime)}
|
{renderRow("cpuTime", t`CPU time`, cpuTime)}
|
||||||
{renderRow("memory", t`Memory`, memoryCurrent, true)}
|
{renderRow("memory", t`Memory`, memoryCurrent, true)}
|
||||||
{renderRow("memoryPeak", t`Memory Peak`, memoryPeak)}
|
{renderRow("memoryPeak", capitalize(t`Memory Peak`), memoryPeak)}
|
||||||
{renderRow("memoryLimit", t`Memory Limit`, memoryLimit)}
|
{renderRow("memoryLimit", t`Memory limit`, memoryLimit)}
|
||||||
{renderRow("restarts", t`Restarts`, restartsValue, true)}
|
{renderRow("restarts", t`Restarts`, restartsValue, true)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -534,7 +536,7 @@ function SystemdSheet({
|
|||||||
)}
|
)}
|
||||||
{renderRow(
|
{renderRow(
|
||||||
"requiredBy",
|
"requiredBy",
|
||||||
t`Required By`,
|
t`Required by`,
|
||||||
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
|
Array.isArray(details?.RequiredBy) && details.RequiredBy.length > 0
|
||||||
? details.RequiredBy.join(", ")
|
? details.RequiredBy.join(", ")
|
||||||
: undefined
|
: undefined
|
||||||
@@ -565,7 +567,7 @@ function SystemdSheet({
|
|||||||
)}
|
)}
|
||||||
{renderRow(
|
{renderRow(
|
||||||
"triggeredBy",
|
"triggeredBy",
|
||||||
t`Triggered By`,
|
t`Triggered by`,
|
||||||
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
|
Array.isArray(details?.TriggeredBy) && details.TriggeredBy.length > 0
|
||||||
? details.TriggeredBy.join(", ")
|
? details.TriggeredBy.join(", ")
|
||||||
: undefined
|
: undefined
|
||||||
@@ -582,11 +584,11 @@ function SystemdSheet({
|
|||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
{renderRow("activeSince", t`Became Active`, activeEnterTimestamp)}
|
{renderRow("activeSince", t`Became active`, activeEnterTimestamp)}
|
||||||
{service.state !== ServiceStatus.Active &&
|
{service.state !== ServiceStatus.Active &&
|
||||||
renderRow("lastActive", t`Exited Active`, activeExitTimestamp)}
|
renderRow("lastActive", t`Exited active`, activeExitTimestamp)}
|
||||||
{renderRow("inactiveSince", t`Became Inactive`, inactiveEnterTimestamp)}
|
{renderRow("inactiveSince", t`Became inactive`, inactiveEnterTimestamp)}
|
||||||
{renderRow("execMainStart", t`Process Started`, execMainStartTimestamp)}
|
{renderRow("execMainStart", t`Process started`, execMainStartTimestamp)}
|
||||||
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
|
{/* {renderRow("invocationId", t`Invocation ID`, details?.InvocationID)} */}
|
||||||
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
|
{/* {renderRow("freezerState", t`Freezer State`, details?.FreezerState)} */}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -601,9 +603,9 @@ function SystemdSheet({
|
|||||||
<div className="border rounded-md">
|
<div className="border rounded-md">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<tbody>
|
<tbody>
|
||||||
{renderRow("canStart", t`Can Start`, details?.CanStart ? t`Yes` : t`No`)}
|
{renderRow("canStart", t`Can start`, details?.CanStart ? t`Yes` : t`No`)}
|
||||||
{renderRow("canStop", t`Can Stop`, details?.CanStop ? 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("canReload", t`Can reload`, details?.CanReload ? t`Yes` : t`No`)}
|
||||||
{/* {renderRow("refuseManualStart", t`Refuse Manual Start`, details?.RefuseManualStart ? 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`)} */}
|
{renderRow("refuseManualStop", t`Refuse Manual Stop`, details?.RefuseManualStop ? t`Yes` : t`No`)} */}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/** biome-ignore-all lint/correctness/useHookAtTopLevel: Hooks live inside memoized column definitions */
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans, useLingui } from "@lingui/react/macro"
|
import { Trans, useLingui } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -16,13 +17,14 @@ import {
|
|||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
PlayCircleIcon,
|
PlayCircleIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
TerminalSquareIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { BatteryState, ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
import {
|
import {
|
||||||
cn,
|
cn,
|
||||||
@@ -33,6 +35,7 @@ import {
|
|||||||
getMeterState,
|
getMeterState,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import { batteryStateTranslations } from "@/lib/i18n"
|
||||||
import type { SystemRecord } from "@/types"
|
import type { SystemRecord } from "@/types"
|
||||||
import { SystemDialog } from "../add-system"
|
import { SystemDialog } from "../add-system"
|
||||||
import AlertButton from "../alerts/alert-button"
|
import AlertButton from "../alerts/alert-button"
|
||||||
@@ -56,7 +59,18 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "../ui/dropdown-menu"
|
} from "../ui/dropdown-menu"
|
||||||
import { EthernetIcon, GpuIcon, HourglassIcon, ThermometerIcon, WebSocketIcon } from "../ui/icons"
|
import {
|
||||||
|
BatteryMediumIcon,
|
||||||
|
EthernetIcon,
|
||||||
|
GpuIcon,
|
||||||
|
HourglassIcon,
|
||||||
|
ThermometerIcon,
|
||||||
|
WebSocketIcon,
|
||||||
|
BatteryHighIcon,
|
||||||
|
BatteryLowIcon,
|
||||||
|
PlugChargingIcon,
|
||||||
|
BatteryFullIcon,
|
||||||
|
} from "../ui/icons"
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
const STATUS_COLORS = {
|
||||||
[SystemStatus.Up]: "bg-green-500",
|
[SystemStatus.Up]: "bg-green-500",
|
||||||
@@ -69,7 +83,7 @@ const STATUS_COLORS = {
|
|||||||
* @param viewMode - "table" or "grid"
|
* @param viewMode - "table" or "grid"
|
||||||
* @returns - Column definitions for the systems table
|
* @returns - Column definitions for the systems table
|
||||||
*/
|
*/
|
||||||
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
// size: 200,
|
// size: 200,
|
||||||
@@ -134,7 +148,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.cpu,
|
accessorFn: ({ info }) => info.cpu || undefined,
|
||||||
id: "cpu",
|
id: "cpu",
|
||||||
name: () => t`CPU`,
|
name: () => t`CPU`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -143,7 +157,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// accessorKey: "info.mp",
|
// accessorKey: "info.mp",
|
||||||
accessorFn: ({ info }) => info.mp,
|
accessorFn: ({ info }) => info.mp || undefined,
|
||||||
id: "memory",
|
id: "memory",
|
||||||
name: () => t`Memory`,
|
name: () => t`Memory`,
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -151,7 +165,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.dp,
|
accessorFn: ({ info }) => info.dp || undefined,
|
||||||
id: "disk",
|
id: "disk",
|
||||||
name: () => t`Disk`,
|
name: () => t`Disk`,
|
||||||
cell: DiskCellWithMultiple,
|
cell: DiskCellWithMultiple,
|
||||||
@@ -159,7 +173,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.g,
|
accessorFn: ({ info }) => info.g || undefined,
|
||||||
id: "gpu",
|
id: "gpu",
|
||||||
name: () => "GPU",
|
name: () => "GPU",
|
||||||
cell: TableCellWithMeter,
|
cell: TableCellWithMeter,
|
||||||
@@ -172,9 +186,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
const sum = info.la?.reduce((acc, curr) => acc + curr, 0)
|
||||||
// TODO: remove this in future release in favor of la array
|
// TODO: remove this in future release in favor of la array
|
||||||
if (!sum) {
|
if (!sum) {
|
||||||
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0)
|
return (info.l1 ?? 0) + (info.l5 ?? 0) + (info.l15 ?? 0) || undefined
|
||||||
}
|
}
|
||||||
return sum
|
return sum || undefined
|
||||||
},
|
},
|
||||||
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
name: () => t({ message: "Load Avg", comment: "Short label for load average" }),
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -217,7 +231,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024,
|
accessorFn: ({ info }) => info.bb || (info.b || 0) * 1024 * 1024 || undefined,
|
||||||
id: "net",
|
id: "net",
|
||||||
name: () => t`Net`,
|
name: () => t`Net`,
|
||||||
size: 0,
|
size: 0,
|
||||||
@@ -229,7 +243,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
if (sys.status === SystemStatus.Paused) {
|
if (sys.status === SystemStatus.Paused) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes((info.getValue() || 0) as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
{decimalString(value, value >= 100 ? 1 : 2)} {unit}
|
||||||
@@ -259,11 +273,95 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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,
|
accessorFn: ({ info }) => info.v,
|
||||||
id: "agent",
|
id: "agent",
|
||||||
name: () => t`Agent`,
|
name: () => t`Agent`,
|
||||||
// invertSorting: true,
|
|
||||||
size: 50,
|
size: 50,
|
||||||
Icon: WifiIcon,
|
Icon: WifiIcon,
|
||||||
hideSort: true,
|
hideSort: true,
|
||||||
@@ -356,15 +454,21 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||||
const { info: sysInfo, status } = info.row.original
|
const { info: sysInfo, status, id } = info.row.original
|
||||||
const rootDiskPct = sysInfo.dp
|
const extraFs = Object.entries(sysInfo.efs ?? {})
|
||||||
const extraFsData = sysInfo.efs
|
|
||||||
const extraFsCount = extraFsData ? Object.keys(extraFsData).length : 0
|
|
||||||
|
|
||||||
function getMeterClass(pct: number) {
|
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)
|
const threshold = getMeterState(pct)
|
||||||
return cn(
|
return (
|
||||||
"h-full",
|
|
||||||
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||||
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
@@ -372,45 +476,66 @@ function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No extra disks - show simple meter
|
function getMeterClass(pct: number) {
|
||||||
if (extraFsCount === 0) {
|
return cn("h-full", getIndicatorColor(pct))
|
||||||
return TableCellWithMeter(info)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Has extra disks - show with tooltip
|
// 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 (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Link href={getPagePath($router, "system", { id: info.row.original.id })} tabIndex={-1} className="flex flex-col gap-0.5 w-full relative z-10">
|
<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">
|
<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="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
<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">
|
||||||
<span className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
{/* Root disk */}
|
||||||
{extraFsData && Object.entries(extraFsData).slice(0, 2).map(([_name, pct], index) => (
|
<span
|
||||||
<span key={index} className={getMeterClass(pct)} style={{ width: `${pct}%` }}></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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="max-w-xs pb-2">
|
<TooltipContent side="right" className="max-w-xs pb-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="grid gap-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
<div className="text-[0.65rem] text-muted-foreground uppercase tabular-nums">{t`Root`}</div>
|
<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">
|
<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="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
<span className="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden">
|
<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 className={getMeterClass(rootDiskPct)} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{extraFsData && Object.entries(extraFsData).map(([name, pct]) => {
|
{extraFs.map(([name, pct]) => {
|
||||||
return (
|
return (
|
||||||
<div key={name} className="flex flex-col gap-0.5">
|
<div key={name} className="grid gap-0.5">
|
||||||
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider truncate">{name}</div>
|
<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">
|
<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="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||||
<span className="flex-1 min-w-12 grid bg-muted/50 h-2 rounded-sm overflow-hidden">
|
<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 className={getMeterClass(pct)} style={{ width: `${pct}%` }}></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -532,5 +657,5 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}, [id, status, host, name, t, deleteOpen, editOpen])
|
}, [id, status, host, name, system, t, deleteOpen, editOpen])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
LayoutGridIcon,
|
LayoutGridIcon,
|
||||||
LayoutListIcon,
|
LayoutListIcon,
|
||||||
Settings2Icon,
|
Settings2Icon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -47,7 +48,7 @@ import type { SystemRecord } from "@/types"
|
|||||||
import AlertButton from "../alerts/alert-button"
|
import AlertButton from "../alerts/alert-button"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import SystemsTableColumns, { ActionsButton, IndicatorDot } from "./systems-table-columns"
|
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
|
||||||
|
|
||||||
type ViewMode = "table" | "grid"
|
type ViewMode = "table" | "grid"
|
||||||
type StatusFilter = "all" | SystemRecord["status"]
|
type StatusFilter = "all" | SystemRecord["status"]
|
||||||
@@ -60,7 +61,7 @@ export default function SystemsTable() {
|
|||||||
const upSystems = $upSystems.get()
|
const upSystems = $upSystems.get()
|
||||||
const pausedSystems = $pausedSystems.get()
|
const pausedSystems = $pausedSystems.get()
|
||||||
const { i18n, t } = useLingui()
|
const { i18n, t } = useLingui()
|
||||||
const [filter, setFilter] = useState<string>()
|
const [filter, setFilter] = useState<string>("")
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||||
"sortMode",
|
"sortMode",
|
||||||
@@ -145,7 +146,26 @@ export default function SystemsTable() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 ms-auto w-full md:w-80">
|
<div className="flex gap-2 ms-auto w-full md:w-80">
|
||||||
<Input placeholder={t`Filter...`} onChange={(e) => setFilter(e.target.value)} className="px-4" />
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={t`Filter...`}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
value={filter}
|
||||||
|
className="ps-4 pe-10 w-full"
|
||||||
|
/>
|
||||||
|
{filter && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t`Clear`}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
|
||||||
|
onClick={() => setFilter("")}
|
||||||
|
>
|
||||||
|
<XIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
@@ -278,6 +298,7 @@ export default function SystemsTable() {
|
|||||||
upSystemsLength,
|
upSystemsLength,
|
||||||
downSystemsLength,
|
downSystemsLength,
|
||||||
pausedSystemsLength,
|
pausedSystemsLength,
|
||||||
|
filter,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
import { chartTimeData, cn } from "@/lib/utils"
|
import { chartTimeData, cn } from "@/lib/utils"
|
||||||
import type { ChartData } from "@/types"
|
import type { ChartData } from "@/types"
|
||||||
import { Separator } from "./separator"
|
import { Separator } from "./separator"
|
||||||
|
import { AxisDomain } from "recharts/types/util/types"
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
@@ -454,3 +455,15 @@ export {
|
|||||||
xAxis,
|
xAxis,
|
||||||
// ChartStyle,
|
// ChartStyle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pinnedAxisDomain(): AxisDomain {
|
||||||
|
return [0, (dataMax: number) => {
|
||||||
|
if (dataMax > 10) {
|
||||||
|
return Math.round(dataMax)
|
||||||
|
}
|
||||||
|
if (dataMax > 1) {
|
||||||
|
return Math.round(dataMax / 0.1) * 0.1
|
||||||
|
}
|
||||||
|
return dataMax
|
||||||
|
}]
|
||||||
|
}
|
||||||
@@ -131,6 +131,7 @@ export function HourglassIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
|
<svg viewBox="0 0 256 193" {...props} fill="currentColor">
|
||||||
@@ -139,3 +140,48 @@ export function WebSocketIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
|
export function BatteryMediumIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||||
|
<path d="M16 13H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
|
export function BatteryLowIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||||
|
<path d="M16 17H8V6h8m.7-2H15V2H9v2H7.3A1.3 1.3 0 0 0 6 5.3v15.4q.1 1.2 1.3 1.3h9.4a1.3 1.3 0 0 0 1.3-1.3V5.3q-.1-1.2-1.3-1.3" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
|
export function BatteryHighIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||||
|
<path d="M16 9H8V6h8m.67-2H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
|
export function BatteryFullIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props} fill="currentColor">
|
||||||
|
<path d="M16.67 4H15V2H9v2H7.33A1.33 1.33 0 0 0 6 5.33v15.34C6 21.4 6.6 22 7.33 22h9.34A1.33 1.33 0 0 0 18 20.67V5.33C18 4.6 17.4 4 16.67 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/phosphor-icons/core (MIT license)
|
||||||
|
export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" {...props} fill="currentColor">
|
||||||
|
<path d="M224,48H180V16a12,12,0,0,0-24,0V48H100V16a12,12,0,0,0-24,0V48H32.55C24.4,48,20,54.18,20,60A12,12,0,0,0,32,72H44v92a44.05,44.05,0,0,0,44,44h28v32a12,12,0,0,0,24,0V208h28a44.05,44.05,0,0,0,44-44V72h12a12,12,0,0,0,0-24ZM188,164a20,20,0,0,1-20,20H88a20,20,0,0,1-20-20V72H188Zm-85.86-29.17a12,12,0,0,1-1.38-11l12-32a12,12,0,1,1,22.48,8.42L129.32,116H144a12,12,0,0,1,11.24,16.21l-12,32a12,12,0,0,1-22.48-8.42L126.68,140H112A12,12,0,0,1,102.14,134.83Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
--table-header: hsl(225, 6%, 97%);
|
--table-header: hsl(225, 6%, 97%);
|
||||||
--chart-saturation: 65%;
|
--chart-saturation: 65%;
|
||||||
--chart-lightness: 50%;
|
--chart-lightness: 50%;
|
||||||
|
--container: 1500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -116,7 +117,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
/* Fonts */
|
/* Fonts */
|
||||||
@supports (font-variation-settings: normal) {
|
@supports (font-variation-settings: normal) {
|
||||||
:root {
|
:root {
|
||||||
@@ -141,6 +141,7 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-variant-ligatures: no-contextual;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@@ -149,7 +150,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility container {
|
@utility container {
|
||||||
@apply max-w-370 mx-auto px-4;
|
max-width: var(--container);
|
||||||
|
@apply mx-auto px-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility link {
|
@utility link {
|
||||||
@@ -159,10 +161,6 @@
|
|||||||
@utility ns-dialog {
|
@utility ns-dialog {
|
||||||
/* New system dialog width */
|
/* New system dialog width */
|
||||||
min-width: 30.3rem;
|
min-width: 30.3rem;
|
||||||
|
|
||||||
:where(:lang(zh), :lang(zh-CN), :lang(ko)) & {
|
|
||||||
min-width: 27.9rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
|
||||||
import type { RecordSubscription } from "pocketbase"
|
import type { RecordSubscription } from "pocketbase"
|
||||||
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
|
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import type { AlertInfo, AlertRecord } from "@/types"
|
import type { AlertInfo, AlertRecord } from "@/types"
|
||||||
import { pb } from "./api"
|
import { pb } from "./api"
|
||||||
|
import { ThermometerIcon, BatteryMediumIcon, HourglassIcon } from "@/components/ui/icons"
|
||||||
|
|
||||||
/** Alert info for each alert type */
|
/** Alert info for each alert type */
|
||||||
export const alertInfo: Record<string, AlertInfo> = {
|
export const alertInfo: Record<string, AlertInfo> = {
|
||||||
@@ -83,6 +84,14 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
||||||
},
|
},
|
||||||
|
Battery: {
|
||||||
|
name: () => t`Battery`,
|
||||||
|
unit: "%",
|
||||||
|
icon: BatteryMediumIcon,
|
||||||
|
desc: () => t`Triggers when battery charge drops below a threshold`,
|
||||||
|
start: 20,
|
||||||
|
invert: true,
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** Helper to manage user alerts */
|
/** Helper to manage user alerts */
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function getLocale() {
|
|||||||
}
|
}
|
||||||
locale = (locale || "en").split("-")[0]
|
locale = (locale || "en").split("-")[0]
|
||||||
// use en if locale is not in languages
|
// use en if locale is not in languages
|
||||||
if (!languages.some((l) => l.lang === locale)) {
|
if (!languages.some((l) => l[0] === locale)) {
|
||||||
locale = "en"
|
locale = "en"
|
||||||
}
|
}
|
||||||
return locale
|
return locale
|
||||||
|
|||||||
@@ -1,142 +1,32 @@
|
|||||||
export default [
|
export default [
|
||||||
{
|
["ar", "العربية", "🇵🇸"],
|
||||||
lang: "ar",
|
["bg", "Български", "🇧🇬"],
|
||||||
label: "العربية",
|
["cs", "Čeština", "🇨🇿"],
|
||||||
e: "🇵🇸",
|
["da", "Dansk", "🇩🇰"],
|
||||||
},
|
["de", "Deutsch", "🇩🇪"],
|
||||||
{
|
["en", "English", "🇬🇧"],
|
||||||
lang: "bg",
|
["es", "Español", "🇪🇸"],
|
||||||
label: "Български",
|
["fa", "فارسی", "🇮🇷"],
|
||||||
e: "🇧🇬",
|
["fr", "Français", "🇫🇷"],
|
||||||
},
|
["he", "עברית", "🕎"],
|
||||||
{
|
["hr", "Hrvatski", "🇭🇷"],
|
||||||
lang: "cs",
|
["hu", "Magyar", "🇭🇺"],
|
||||||
label: "Čeština",
|
["id", "Indonesia", "🇮🇩"],
|
||||||
e: "🇨🇿",
|
["it", "Italiano", "🇮🇹"],
|
||||||
},
|
["ja", "日本語", "🇯🇵"],
|
||||||
{
|
["ko", "한국어", "🇰🇷"],
|
||||||
lang: "da",
|
["nl", "Nederlands", "🇳🇱"],
|
||||||
label: "Dansk",
|
["no", "Norsk", "🇳🇴"],
|
||||||
e: "🇩🇰",
|
["pl", "Polski", "🇵🇱"],
|
||||||
},
|
["pt", "Português", "🇵🇹"],
|
||||||
{
|
["ru", "Русский", "🇷🇺"],
|
||||||
lang: "de",
|
["sl", "Slovenščina", "🇸🇮"],
|
||||||
label: "Deutsch",
|
["sr", "Српски", "🇷🇸"],
|
||||||
e: "🇩🇪",
|
["sv", "Svenska", "🇸🇪"],
|
||||||
},
|
["tr", "Türkçe", "🇹🇷"],
|
||||||
{
|
["uk", "Українська", "🇺🇦"],
|
||||||
lang: "en",
|
["vi", "Tiếng Việt", "🇻🇳"],
|
||||||
label: "English",
|
["zh-CN", "简体中文", "🇨🇳"],
|
||||||
e: "🇺🇸",
|
["zh-HK", "繁體中文", "🇭🇰"],
|
||||||
},
|
["zh", "繁體中文", "🇹🇼"],
|
||||||
{
|
|
||||||
lang: "es",
|
|
||||||
label: "Español",
|
|
||||||
e: "🇲🇽",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "fa",
|
|
||||||
label: "فارسی",
|
|
||||||
e: "🇮🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "fr",
|
|
||||||
label: "Français",
|
|
||||||
e: "🇫🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "he",
|
|
||||||
label: "עברית",
|
|
||||||
e: "🕎",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "hr",
|
|
||||||
label: "Hrvatski",
|
|
||||||
e: "🇭🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "hu",
|
|
||||||
label: "Magyar",
|
|
||||||
e: "🇭🇺",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "it",
|
|
||||||
label: "Italiano",
|
|
||||||
e: "🇮🇹",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "ja",
|
|
||||||
label: "日本語",
|
|
||||||
e: "🇯🇵",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "ko",
|
|
||||||
label: "한국어",
|
|
||||||
e: "🇰🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "nl",
|
|
||||||
label: "Nederlands",
|
|
||||||
e: "🇳🇱",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "no",
|
|
||||||
label: "Norsk",
|
|
||||||
e: "🇳🇴",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "pl",
|
|
||||||
label: "Polski",
|
|
||||||
e: "🇵🇱",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "pt",
|
|
||||||
label: "Português",
|
|
||||||
e: "🇧🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "tr",
|
|
||||||
label: "Türkçe",
|
|
||||||
e: "🇹🇷",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "ru",
|
|
||||||
label: "Русский",
|
|
||||||
e: "🇷🇺",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "sl",
|
|
||||||
label: "Slovenščina",
|
|
||||||
e: "🇸🇮",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "sv",
|
|
||||||
label: "Svenska",
|
|
||||||
e: "🇸🇪",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "uk",
|
|
||||||
label: "Українська",
|
|
||||||
e: "🇺🇦",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "vi",
|
|
||||||
label: "Tiếng Việt",
|
|
||||||
e: "🇻🇳",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "zh-CN",
|
|
||||||
label: "简体中文",
|
|
||||||
e: "🇨🇳",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "zh-HK",
|
|
||||||
label: "繁體中文",
|
|
||||||
e: "🇭🇰",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lang: "zh",
|
|
||||||
label: "繁體中文",
|
|
||||||
e: "🇹🇼",
|
|
||||||
},
|
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user