mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-05 05:56:11 +00:00
Compare commits
5 Commits
temp-fix-1
...
temp-pve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd74ab8d7b | ||
|
|
016d775675 | ||
|
|
bdbd135fdd | ||
|
|
48503f9f99 | ||
|
|
d34ef1ebe9 |
@@ -33,6 +33,7 @@ type Agent struct {
|
||||
netIoStats map[uint16]system.NetIoStats // Keeps track of bandwidth usage per cache interval
|
||||
netInterfaceDeltaTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64] // Per-cache-time NIC delta trackers
|
||||
dockerManager *dockerManager // Manages Docker API requests
|
||||
pveManager *pveManager // Manages Proxmox VE API requests
|
||||
sensorConfig *SensorConfig // Sensors config
|
||||
systemInfo system.Info // Host system info (dynamic)
|
||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||
@@ -99,6 +100,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
// initialize docker manager
|
||||
agent.dockerManager = newDockerManager()
|
||||
|
||||
// initialize pve manager
|
||||
agent.pveManager = newPVEManager()
|
||||
|
||||
// initialize system info
|
||||
agent.refreshSystemDetails()
|
||||
|
||||
@@ -189,6 +193,15 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
}
|
||||
}
|
||||
|
||||
if a.pveManager != nil {
|
||||
if pveStats, err := a.pveManager.getPVEStats(); err == nil {
|
||||
data.PVEStats = pveStats
|
||||
slog.Debug("PVE", "data", data.PVEStats)
|
||||
} else {
|
||||
slog.Debug("PVE", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
)
|
||||
|
||||
func createTestCacheData() *system.CombinedData {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 10.5
|
||||
stats.Mem = 1073741824 // 1GB
|
||||
return &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 50.5,
|
||||
@@ -24,10 +28,7 @@ func createTestCacheData() *system.CombinedData {
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
Name: "test-container",
|
||||
Cpu: 25.0,
|
||||
},
|
||||
&stats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ import (
|
||||
|
||||
// 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\\-_]`)
|
||||
var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
var (
|
||||
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`)
|
||||
dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
)
|
||||
|
||||
const (
|
||||
// Docker API timeout in milliseconds
|
||||
@@ -395,11 +397,12 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
|
||||
// add empty values if they doesn't exist in map
|
||||
stats, initialized := dm.containerStatsMap[ctr.IdShort]
|
||||
if !initialized {
|
||||
stats = &container.Stats{Name: name, Id: ctr.IdShort, Image: ctr.Image}
|
||||
stats = &container.Stats{Image: ctr.Image}
|
||||
dm.containerStatsMap[ctr.IdShort] = stats
|
||||
}
|
||||
|
||||
stats.Id = ctr.IdShort
|
||||
stats.Name = name
|
||||
|
||||
statusText, health := parseDockerStatus(ctr.Status)
|
||||
stats.Status = statusText
|
||||
|
||||
@@ -269,17 +269,16 @@ func TestValidateCpuPercentage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateContainerStatsValues(t *testing.T) {
|
||||
stats := &container.Stats{
|
||||
Name: "test-container",
|
||||
Cpu: 0.0,
|
||||
Mem: 0.0,
|
||||
NetworkSent: 0.0,
|
||||
NetworkRecv: 0.0,
|
||||
PrevReadTime: time.Time{},
|
||||
}
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 0.0
|
||||
stats.Mem = 0.0
|
||||
stats.NetworkSent = 0.0
|
||||
stats.NetworkRecv = 0.0
|
||||
stats.PrevReadTime = time.Time{}
|
||||
|
||||
testTime := time.Now()
|
||||
updateContainerStatsValues(stats, 75.5, 1048576, 524288, 262144, testTime)
|
||||
updateContainerStatsValues(&stats, 75.5, 1048576, 524288, 262144, testTime)
|
||||
|
||||
// Check CPU percentage (should be rounded to 2 decimals)
|
||||
assert.Equal(t, 75.5, stats.Cpu)
|
||||
@@ -446,12 +445,11 @@ func TestCalculateNetworkStats(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
stats := &container.Stats{
|
||||
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
|
||||
}
|
||||
var stats = container.Stats{}
|
||||
stats.PrevReadTime = time.Now().Add(-time.Second) // 1 second ago
|
||||
|
||||
// Test with initialized container
|
||||
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
||||
sent, recv := dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||
|
||||
// Should return calculated byte rates per second
|
||||
assert.GreaterOrEqual(t, sent, uint64(0))
|
||||
@@ -460,7 +458,7 @@ func TestCalculateNetworkStats(t *testing.T) {
|
||||
// Cycle and test one-direction change (Tx only) is reflected independently
|
||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
|
||||
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
|
||||
sent, recv = dm.calculateNetworkStats(ctr, apiStats, &stats, true, "test-container", cacheTimeMs)
|
||||
assert.Greater(t, sent, uint64(0))
|
||||
assert.Equal(t, uint64(0), recv)
|
||||
}
|
||||
@@ -726,7 +724,8 @@ func TestMemoryStatsEdgeCases(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContainerStatsInitialization(t *testing.T) {
|
||||
stats := &container.Stats{Name: "test-container"}
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
|
||||
// Verify initial values
|
||||
assert.Equal(t, "test-container", stats.Name)
|
||||
@@ -738,7 +737,7 @@ func TestContainerStatsInitialization(t *testing.T) {
|
||||
|
||||
// Test updating values
|
||||
testTime := time.Now()
|
||||
updateContainerStatsValues(stats, 45.67, 2097152, 1048576, 524288, testTime)
|
||||
updateContainerStatsValues(&stats, 45.67, 2097152, 1048576, 524288, testTime)
|
||||
|
||||
assert.Equal(t, 45.67, stats.Cpu)
|
||||
assert.Equal(t, 2.0, stats.Mem)
|
||||
@@ -816,12 +815,11 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
|
||||
// Use exact timing for deterministic results
|
||||
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
|
||||
stats := &container.Stats{
|
||||
PrevReadTime: exactly1000msAgo,
|
||||
}
|
||||
var stats = container.Stats{}
|
||||
stats.PrevReadTime = exactly1000msAgo
|
||||
|
||||
// First call sets baseline
|
||||
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
|
||||
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs)
|
||||
assert.Equal(t, uint64(0), sent1)
|
||||
assert.Equal(t, uint64(0), recv1)
|
||||
|
||||
@@ -836,7 +834,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
|
||||
|
||||
// Second call with changed data
|
||||
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
||||
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||
|
||||
// Should be exactly the expected rates (no tolerance needed)
|
||||
assert.Equal(t, expectedSentRate, sent2)
|
||||
@@ -847,9 +845,9 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
|
||||
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
|
||||
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
|
||||
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
|
||||
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
|
||||
_, _ = dm.calculateNetworkStats(ctr, apiStats1, &stats, true, "test", cacheTimeMs) // baseline
|
||||
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
|
||||
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
|
||||
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, &stats, true, "test", cacheTimeMs)
|
||||
assert.Equal(t, uint64(0), sent3)
|
||||
assert.Equal(t, uint64(0), recv3)
|
||||
}
|
||||
@@ -883,8 +881,9 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
|
||||
}
|
||||
|
||||
// Initialize container stats
|
||||
stats := &container.Stats{Name: "jellyfin"}
|
||||
dm.containerStatsMap[ctr.IdShort] = stats
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "jellyfin"
|
||||
dm.containerStatsMap[ctr.IdShort] = &stats
|
||||
|
||||
// Test individual components that we can verify
|
||||
usedMemory, memErr := calculateMemoryUsage(&apiStats, false)
|
||||
|
||||
158
agent/pve.go
Normal file
158
agent/pve.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
|
||||
"github.com/luthermonson/go-proxmox"
|
||||
)
|
||||
|
||||
type pveManager struct {
|
||||
client *proxmox.Client // Client to query PVE API
|
||||
nodeName string // Cluster node name
|
||||
cpuCount int // CPU count on node
|
||||
nodeStatsMap map[string]*container.PveNodeStats // Keeps track of pve node stats
|
||||
}
|
||||
|
||||
// Returns stats for all running VMs/LXCs
|
||||
func (pm *pveManager) getPVEStats() ([]*container.PveNodeStats, error) {
|
||||
if pm.client == nil {
|
||||
return nil, errors.New("PVE client not configured")
|
||||
}
|
||||
cluster, err := pm.client.Cluster(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Error getting cluster", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
resources, err := cluster.Resources(context.Background(), "vm")
|
||||
if err != nil {
|
||||
slog.Error("Error getting resources", "err", err, "resources", resources)
|
||||
return nil, err
|
||||
}
|
||||
containersLength := len(resources)
|
||||
containerIds := make(map[string]struct{}, containersLength)
|
||||
|
||||
// only include running vms and lxcs on selected node
|
||||
for _, resource := range resources {
|
||||
if resource.Node == pm.nodeName && resource.Status == "running" {
|
||||
containerIds[resource.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
// remove invalid container stats
|
||||
for id := range pm.nodeStatsMap {
|
||||
if _, exists := containerIds[id]; !exists {
|
||||
delete(pm.nodeStatsMap, id)
|
||||
}
|
||||
}
|
||||
|
||||
// populate stats
|
||||
stats := make([]*container.PveNodeStats, 0, len(containerIds))
|
||||
for _, resource := range resources {
|
||||
if _, exists := containerIds[resource.ID]; !exists {
|
||||
continue
|
||||
}
|
||||
resourceStats, initialized := pm.nodeStatsMap[resource.ID]
|
||||
if !initialized {
|
||||
resourceStats = &container.PveNodeStats{}
|
||||
pm.nodeStatsMap[resource.ID] = resourceStats
|
||||
}
|
||||
// reset current stats
|
||||
resourceStats.Cpu = 0
|
||||
resourceStats.Mem = 0
|
||||
resourceStats.Bandwidth = [2]uint64{0, 0}
|
||||
// Store clean name (no type suffix)
|
||||
resourceStats.Name = resource.Name
|
||||
// Store resource ID (e.g. "qemu/100") in .Id (cbor key 7, json:"-")
|
||||
resourceStats.Id = resource.ID
|
||||
// Store type (e.g. "qemu" or "lxc") in .Image (cbor key 8, json:"-")
|
||||
resourceStats.Type = resource.Type
|
||||
// PVE limits (cbor-only, for pve_vms table)
|
||||
resourceStats.MaxCPU = resource.MaxCPU
|
||||
resourceStats.MaxMem = resource.MaxMem
|
||||
resourceStats.Uptime = resource.Uptime
|
||||
// prevent first run from sending all prev sent/recv bytes
|
||||
total_sent := uint64(resource.NetOut)
|
||||
total_recv := uint64(resource.NetIn)
|
||||
var sent_delta, recv_delta float64
|
||||
if initialized {
|
||||
secondsElapsed := time.Since(resourceStats.PrevReadTime).Seconds()
|
||||
if secondsElapsed > 0 {
|
||||
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
|
||||
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
|
||||
}
|
||||
}
|
||||
resourceStats.PrevNet.Sent = total_sent
|
||||
resourceStats.PrevNet.Recv = total_recv
|
||||
resourceStats.PrevReadTime = time.Now()
|
||||
|
||||
// Update final stats values
|
||||
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
|
||||
resourceStats.Mem = bytesToMegabytes(float64(resource.Mem))
|
||||
resourceStats.Bandwidth = [2]uint64{uint64(sent_delta), uint64(recv_delta)}
|
||||
|
||||
stats = append(stats, resourceStats)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Creates a new PVE manager - may return nil if required environment variables are not set or if there is an error connecting to the API
|
||||
func newPVEManager() *pveManager {
|
||||
url, exists := GetEnv("PROXMOX_URL")
|
||||
if !exists {
|
||||
url = "https://localhost:8006/api2/json"
|
||||
}
|
||||
const nodeEnvVar = "PROXMOX_NODE"
|
||||
const tokenIDEnvVar = "PROXMOX_TOKENID"
|
||||
const secretEnvVar = "PROXMOX_SECRET"
|
||||
|
||||
nodeName, nodeNameExists := GetEnv(nodeEnvVar)
|
||||
tokenID, tokenIDExists := GetEnv(tokenIDEnvVar)
|
||||
secret, secretExists := GetEnv(secretEnvVar)
|
||||
|
||||
if !nodeNameExists || !tokenIDExists || !secretExists {
|
||||
slog.Debug("Proxmox env vars unset", nodeEnvVar, nodeNameExists, tokenIDEnvVar, tokenIDExists, secretEnvVar, secretExists)
|
||||
return nil
|
||||
}
|
||||
|
||||
// PROXMOX_INSECURE_TLS defaults to true; set to "false" to enable TLS verification
|
||||
insecureTLS := true
|
||||
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
|
||||
insecureTLS = val != "false"
|
||||
}
|
||||
|
||||
httpClient := http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: insecureTLS,
|
||||
},
|
||||
},
|
||||
}
|
||||
client := proxmox.NewClient(url,
|
||||
proxmox.WithHTTPClient(&httpClient),
|
||||
proxmox.WithAPIToken(tokenID, secret),
|
||||
)
|
||||
|
||||
pveManager := pveManager{
|
||||
client: client,
|
||||
nodeName: nodeName,
|
||||
nodeStatsMap: make(map[string]*container.PveNodeStats),
|
||||
}
|
||||
|
||||
// Retrieve node cpu count
|
||||
node, err := client.Node(context.Background(), nodeName)
|
||||
if err != nil {
|
||||
slog.Error("Error connecting to Proxmox", "err", err)
|
||||
return nil
|
||||
} else {
|
||||
pveManager.cpuCount = node.CPUInfo.CPUs
|
||||
}
|
||||
|
||||
return &pveManager
|
||||
}
|
||||
@@ -559,6 +559,10 @@ func TestWriteToSessionEncoding(t *testing.T) {
|
||||
|
||||
// Helper function to create test data for encoding tests
|
||||
func createTestCombinedData() *system.CombinedData {
|
||||
var stats = container.Stats{}
|
||||
stats.Name = "test-container"
|
||||
stats.Cpu = 10.5
|
||||
stats.Mem = 1073741824 // 1GB
|
||||
return &system.CombinedData{
|
||||
Stats: system.Stats{
|
||||
Cpu: 25.5,
|
||||
@@ -577,11 +581,7 @@ func createTestCombinedData() *system.CombinedData {
|
||||
AgentVersion: "0.12.0",
|
||||
},
|
||||
Containers: []*container.Stats{
|
||||
{
|
||||
Name: "test-container",
|
||||
Cpu: 10.5,
|
||||
Mem: 1073741824, // 1GB
|
||||
},
|
||||
&stats,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
7
go.mod
7
go.mod
@@ -10,6 +10,7 @@ require (
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/luthermonson/go-proxmox v0.4.0
|
||||
github.com/lxzan/gws v1.8.9
|
||||
github.com/nicholas-fedor/shoutrrr v0.13.2
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
@@ -28,8 +29,11 @@ require (
|
||||
require (
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/diskfs/go-diskfs v1.7.0 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/dolthub/maphash v0.1.0 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@@ -41,9 +45,12 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/copier v0.3.4 // indirect
|
||||
github.com/klauspost/compress v1.18.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
||||
34
go.sum
34
go.sum
@@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs=
|
||||
github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
@@ -9,6 +11,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY=
|
||||
github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -17,8 +21,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/diskfs/go-diskfs v1.7.0 h1:vonWmt5CMowXwUc79jWyGrf2DIMeoOjkLlMnQYGVOs8=
|
||||
github.com/diskfs/go-diskfs v1.7.0/go.mod h1:LhQyXqOugWFRahYUSw47NyZJPezFzB9UELwhpszLP/k=
|
||||
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
|
||||
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
@@ -27,6 +35,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab h1:h1UgjJdAAhj+uPL68n7XASS6bU+07ZX1WJvVS2eyoeY=
|
||||
github.com/elliotwutingfeng/asciiset v0.0.0-20230602022725-51bbb787efab/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -51,6 +61,8 @@ github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtS
|
||||
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
@@ -62,6 +74,12 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/v
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
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/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
|
||||
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
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=
|
||||
@@ -69,6 +87,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
|
||||
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/copier v0.3.4 h1:mfU6jI9PtCeUjkjQ322dlff9ELjGDu975C2p/nrubVI=
|
||||
github.com/jinzhu/copier v0.3.4/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -77,8 +97,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
|
||||
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/luthermonson/go-proxmox v0.4.0 h1:LKXpG9d64zTaQF79wV0kfOnnSwIcdG39m7sc4ga+XZs=
|
||||
github.com/luthermonson/go-proxmox v0.4.0/go.mod h1:U6dAkJ+iiwaeb1g/LMWpWuWN4nmvWeXhmoMuYJMumS4=
|
||||
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
|
||||
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
|
||||
github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
|
||||
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -91,6 +115,10 @@ github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/pierrec/lz4/v4 v4.1.17 h1:kV4Ip+/hUBC+8T6+2EgburRtkE9ef4nbY3f4dFhGjMc=
|
||||
github.com/pierrec/lz4/v4 v4.1.17/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/xattr v0.4.9 h1:5883YPCtkSd8LFbs13nXplj9g9tlrwoJRjgpgMu1/fE=
|
||||
github.com/pkg/xattr v0.4.9/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU=
|
||||
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -107,6 +135,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
|
||||
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af h1:Sp5TG9f7K39yfB+If0vjp97vuT74F72r8hfRpP8jLU0=
|
||||
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
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/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -122,6 +152,8 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -148,6 +180,8 @@ 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-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-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
|
||||
@@ -127,22 +127,38 @@ var DockerHealthStrings = map[string]DockerHealth{
|
||||
"unhealthy": DockerHealthUnhealthy,
|
||||
}
|
||||
|
||||
// Docker container stats
|
||||
type Stats struct {
|
||||
Name string `json:"n" cbor:"0,keyasint"`
|
||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
|
||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||
Status string `json:"-" cbor:"6,keyasint"`
|
||||
Id string `json:"-" cbor:"7,keyasint"`
|
||||
Image string `json:"-" cbor:"8,keyasint"`
|
||||
// PrevCpu [2]uint64 `json:"-"`
|
||||
CpuSystem uint64 `json:"-"`
|
||||
CpuContainer uint64 `json:"-"`
|
||||
// SharedCoreMetrics contains fields that are common to both container Stats and PveNodeStats
|
||||
type SharedCoreMetrics struct {
|
||||
Name string `json:"n" cbor:"0,keyasint"`
|
||||
Cpu float64 `json:"c" cbor:"1,keyasint"`
|
||||
Mem float64 `json:"m" cbor:"2,keyasint"`
|
||||
NetworkSent float64 `json:"ns,omitzero" cbor:"3,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
NetworkRecv float64 `json:"nr,omitzero" cbor:"4,keyasint,omitzero"` // deprecated 0.18.3 (MB) - keep field for old agents/records
|
||||
Id string `json:"-" cbor:"7,keyasint"`
|
||||
Bandwidth [2]uint64 `json:"b,omitzero" cbor:"9,keyasint,omitzero"` // [sent bytes, recv bytes]
|
||||
PrevNet prevNetStats `json:"-"`
|
||||
PrevReadTime time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// Stats holds data specific to docker containers for the containers table
|
||||
type Stats struct {
|
||||
SharedCoreMetrics // used to populate stats field in container_stats
|
||||
|
||||
// fields used for containers table
|
||||
|
||||
Health DockerHealth `json:"-" cbor:"5,keyasint"`
|
||||
Status string `json:"-" cbor:"6,keyasint"`
|
||||
Image string `json:"-" cbor:"8,keyasint"`
|
||||
}
|
||||
|
||||
// PveNodeStats holds data specific to PVE nodes for the pve_vms table
|
||||
type PveNodeStats struct {
|
||||
SharedCoreMetrics // used to populate stats field in pve_stats
|
||||
|
||||
// fields used for pve_vms table
|
||||
|
||||
MaxCPU uint64 `json:"-" cbor:"10,keyasint,omitzero"` // PVE: max vCPU count
|
||||
MaxMem uint64 `json:"-" cbor:"11,keyasint,omitzero"` // PVE: max memory bytes
|
||||
Uptime uint64 `json:"-" cbor:"12,keyasint,omitzero"` // PVE: uptime in seconds
|
||||
Type string `json:"-" cbor:"13,keyasint,omitzero"` // PVE: resource type (e.g. "qemu" or "lxc")
|
||||
}
|
||||
|
||||
@@ -170,9 +170,10 @@ type Details struct {
|
||||
|
||||
// Final data structure to return to the hub
|
||||
type CombinedData struct {
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
PVEStats []*container.PveNodeStats `json:"pve,omitempty" cbor:"5,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -209,6 +210,28 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
}
|
||||
}
|
||||
|
||||
// add pve_vms and pve_stats records
|
||||
if len(data.PVEStats) > 0 {
|
||||
if data.PVEStats[0].Id != "" {
|
||||
if err := createPVEVMRecords(txApp, data.PVEStats, sys.Id); err != nil {
|
||||
slog.Error("Error creating PVE VM records", "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
pveStatsCollection, err := txApp.FindCachedCollectionByNameOrId("pve_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pveStatsRecord := core.NewRecord(pveStatsCollection)
|
||||
pveStatsRecord.Set("system", systemRecord.Id)
|
||||
pveStatsRecord.Set("stats", data.PVEStats)
|
||||
pveStatsRecord.Set("type", "1m")
|
||||
if err := txApp.SaveNoValidate(pveStatsRecord); err != nil {
|
||||
slog.Error("Error creating PVE stats records", "err", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// add new systemd_stats record
|
||||
if len(data.SystemdServices) > 0 {
|
||||
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
|
||||
@@ -331,8 +354,40 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
||||
return err
|
||||
}
|
||||
|
||||
// createPVEVMRecords creates or updates pve_vms records
|
||||
func createPVEVMRecords(app core.App, data []*container.PveNodeStats, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
// shared params for all records
|
||||
params := dbx.Params{
|
||||
"system": systemId,
|
||||
"updated": time.Now().UTC().UnixMilli(),
|
||||
}
|
||||
valueStrings := make([]string, 0, len(data))
|
||||
for i, vm := range data {
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:type%[1]s}, {:cpu%[1]s}, {:mem%[1]s}, {:net%[1]s}, {:maxcpu%[1]s}, {:maxmem%[1]s}, {:uptime%[1]s}, {:updated})", suffix))
|
||||
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
|
||||
params["name"+suffix] = vm.Name
|
||||
params["type"+suffix] = vm.Type // "qemu" or "lxc"
|
||||
params["cpu"+suffix] = vm.Cpu
|
||||
params["mem"+suffix] = vm.Mem
|
||||
params["maxcpu"+suffix] = vm.MaxCPU
|
||||
params["maxmem"+suffix] = vm.MaxMem
|
||||
params["uptime"+suffix] = vm.Uptime
|
||||
netBytes := vm.Bandwidth[0] + vm.Bandwidth[1]
|
||||
params["net"+suffix] = netBytes
|
||||
}
|
||||
queryString := fmt.Sprintf(
|
||||
"INSERT INTO pve_vms (id, system, name, type, cpu, mem, net, maxcpu, maxmem, uptime, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, mem=excluded.mem, net=excluded.net, maxcpu=excluded.maxcpu, maxmem=excluded.maxmem, uptime=excluded.uptime, updated=excluded.updated",
|
||||
strings.Join(valueStrings, ","),
|
||||
)
|
||||
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
|
||||
return err
|
||||
}
|
||||
|
||||
// getRecord retrieves the system record from the database.
|
||||
// If the record is not found, it removes the system from the manager.
|
||||
func (sys *System) getRecord() (*core.Record, error) {
|
||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
|
||||
@@ -1685,7 +1685,254 @@ func init() {
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"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": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "pve_stats_sys01",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_json1",
|
||||
"maxSize": 2000000,
|
||||
"name": "stats",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_type1",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"1m",
|
||||
"10m",
|
||||
"20m",
|
||||
"120m",
|
||||
"480m"
|
||||
]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_crt1",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_stats_upd1",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pvestats",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_pve_stats_sys_type_created` + "`" + ` ON ` + "`" + `pve_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||
"name": "pve_stats",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-f0-9]{8}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 8,
|
||||
"min": 8,
|
||||
"name": "id",
|
||||
"pattern": "^[a-f0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_sys001",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_name01",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "pve_vms_type01",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "type",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_cpu001",
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"name": "cpu",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_mem001",
|
||||
"max": null,
|
||||
"min": 0,
|
||||
"name": "mem",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_net001",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "net",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1253106325",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "maxcpu",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1693954525",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "maxmem",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "number1563400775",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "uptime",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "pve_vms_upd001",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "updated",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"id": "pvevms",
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_pve_vms_updated` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `updated` + "`" + `)",
|
||||
"CREATE INDEX ` + "`" + `idx_pve_vms_system` + "`" + ` ON ` + "`" + `pve_vms` + "`" + ` (` + "`" + `system` + "`" + `)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && system.users.id ?= @request.auth.id",
|
||||
"name": "pve_vms",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
}
|
||||
|
||||
]`
|
||||
|
||||
err := app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
||||
@@ -42,11 +42,11 @@ type StatsRecord struct {
|
||||
// global variables for reusing allocations
|
||||
var (
|
||||
statsRecord StatsRecord
|
||||
containerStats []container.Stats
|
||||
containerStats []container.SharedCoreMetrics
|
||||
sumStats system.Stats
|
||||
tempStats system.Stats
|
||||
queryParams = make(dbx.Params, 1)
|
||||
containerSums = make(map[string]*container.Stats)
|
||||
containerSums = make(map[string]*container.SharedCoreMetrics)
|
||||
)
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
@@ -82,7 +82,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// wrap the operations in a transaction
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
collections := [2]*core.Collection{}
|
||||
collections := [3]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,6 +91,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("pve_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
db := txApp.DB()
|
||||
|
||||
@@ -150,8 +154,9 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||
case "container_stats":
|
||||
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "container_stats"))
|
||||
case "pve_stats":
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, "pve_stats"))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err)
|
||||
@@ -435,8 +440,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
return sum
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
// Calculate the average stats of a list of container_stats or pve_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.SharedCoreMetrics {
|
||||
// Clear global map for reuse
|
||||
for k := range containerSums {
|
||||
delete(containerSums, k)
|
||||
@@ -453,15 +458,15 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
containerStats = nil
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord)
|
||||
|
||||
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
||||
return []container.Stats{}
|
||||
return []container.SharedCoreMetrics{}
|
||||
}
|
||||
for i := range containerStats {
|
||||
stat := containerStats[i]
|
||||
if _, ok := sums[stat.Name]; !ok {
|
||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||
sums[stat.Name] = &container.SharedCoreMetrics{Name: stat.Name}
|
||||
}
|
||||
sums[stat.Name].Cpu += stat.Cpu
|
||||
sums[stat.Name].Mem += stat.Mem
|
||||
@@ -476,9 +481,9 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]container.Stats, 0, len(sums))
|
||||
result := make([]container.SharedCoreMetrics, 0, len(sums))
|
||||
for _, value := range sums {
|
||||
result = append(result, container.Stats{
|
||||
result = append(result, container.SharedCoreMetrics{
|
||||
Name: value.Name,
|
||||
Cpu: twoDecimals(value.Cpu / count),
|
||||
Mem: twoDecimals(value.Mem / count),
|
||||
@@ -499,6 +504,10 @@ func (rm *RecordManager) DeleteOldRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldPVEVMRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -537,7 +546,7 @@ func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int)
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [2]string{"system_stats", "container_stats"}
|
||||
collections := [3]string{"system_stats", "container_stats", "pve_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
@@ -590,6 +599,19 @@ func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes pve_vms records that haven't been updated in the last 10 minutes
|
||||
func deleteOldPVEVMRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
_, err := app.DB().NewQuery("DELETE FROM pve_vms WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old pve_vms records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
@@ -23,14 +23,16 @@ export default memo(function ContainerChart({
|
||||
chartType,
|
||||
chartConfig,
|
||||
unit = "%",
|
||||
filterStore = $containerFilter,
|
||||
}: {
|
||||
dataKey: string
|
||||
chartData: ChartData
|
||||
chartType: ChartType
|
||||
chartConfig: ChartConfig
|
||||
unit?: string
|
||||
filterStore?: typeof $containerFilter
|
||||
}) {
|
||||
const filter = useStore($containerFilter)
|
||||
const filter = useStore(filterStore)
|
||||
const userSettings = useStore($userSettings)
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import {
|
||||
ContainerIcon,
|
||||
@@ -31,6 +32,7 @@ import { Logo } from "./logo"
|
||||
import { ModeToggle } from "./mode-toggle"
|
||||
import { $router, basePath, Link, prependBasePath } from "./router"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
import { ProxmoxIcon } from "./ui/icons"
|
||||
|
||||
const CommandPalette = lazy(() => import("./command-palette"))
|
||||
|
||||
@@ -77,6 +79,20 @@ export default function Navbar() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "proxmox")}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label={t`Proxmox`}
|
||||
>
|
||||
<ProxmoxIcon className="h-[1.2rem] w-[1.2rem] opacity-90" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Proxmox</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
|
||||
170
internal/site/src/components/pve-table/pve-table-columns.tsx
Normal file
170
internal/site/src/components/pve-table/pve-table-columns.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, formatBytes, hourWithSeconds, toFixedFloat } from "@/lib/utils"
|
||||
import type { PveVmRecord } from "@/types"
|
||||
import {
|
||||
ArrowUpDownIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
MemoryStickIcon,
|
||||
MonitorIcon,
|
||||
ServerIcon,
|
||||
TagIcon,
|
||||
TimerIcon,
|
||||
} from "lucide-react"
|
||||
import { EthernetIcon } from "../ui/icons"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
/** Format uptime in seconds to a human-readable string */
|
||||
export function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) {
|
||||
const m = Math.floor(seconds / 60)
|
||||
return `${m}m`
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||
}
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
return h > 0 ? `${d}d ${h}h` : `${d}d`
|
||||
}
|
||||
|
||||
export const pveVmCols: ColumnDef<PveVmRecord>[] = [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
||||
accessorFn: (record) => record.name,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={MonitorIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 xl:w-48 block truncate">{getValue() as string}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
return systemNameA.localeCompare(systemNameB)
|
||||
},
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorFn: (record) => record.type,
|
||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={TagIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const type = getValue() as string
|
||||
return (
|
||||
<Badge variant="outline" className="dark:border-white/12 ms-1.5">
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorFn: (record) => record.cpu,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`CPU`} Icon={CpuIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
return <span className="ms-1.5 tabular-nums">{`${decimalString(val, val >= 10 ? 1 : 2)}%`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "mem",
|
||||
accessorFn: (record) => record.mem,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Memory`} Icon={MemoryStickIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, false, undefined, true)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "net",
|
||||
accessorFn: (record) => record.net,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Net`} Icon={EthernetIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue() as number
|
||||
const formatted = formatBytes(val, true, undefined, false)
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums">{`${decimalString(formatted.value, formatted.value >= 10 ? 1 : 2)} ${formatted.unit}`}</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxcpu",
|
||||
accessorFn: (record) => record.maxcpu,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`vCPUs`} Icon={CpuIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 tabular-nums">{getValue() as number}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "maxmem",
|
||||
accessorFn: (record) => record.maxmem,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Max Mem`} Icon={MemoryStickIcon} />,
|
||||
invertSorting: true,
|
||||
cell: ({ getValue }) => {
|
||||
// maxmem is stored in bytes; convert to MB for formatBytes
|
||||
const formatted = formatBytes(getValue() as number, false, undefined, false)
|
||||
return <span className="ms-1.5 tabular-nums">{`${toFixedFloat(formatted.value, 2)} ${formatted.unit}`}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorFn: (record) => record.uptime,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Uptime`} Icon={TimerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
return <span className="ms-1.5 w-25 block truncate">{formatUptime(getValue() as number)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<PveVmRecord>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
<ArrowUpDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
368
internal/site/src/components/pve-table/pve-table.tsx
Normal file
368
internal/site/src/components/pve-table/pve-table.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { memo, RefObject, useEffect, useRef, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { pb } from "@/lib/api"
|
||||
import type { PveVmRecord } from "@/types"
|
||||
import { pveVmCols, formatUptime } from "@/components/pve-table/pve-table-columns"
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { cn, decimalString, formatBytes, useBrowserStorage } from "@/lib/utils"
|
||||
import { Sheet, SheetTitle, SheetHeader, SheetContent, SheetDescription } from "../ui/sheet"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { LoaderCircleIcon, XIcon } from "lucide-react"
|
||||
import { Separator } from "../ui/separator"
|
||||
import { $router, Link } from "../router"
|
||||
import { listenKeys } from "nanostores"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
|
||||
export default function PveTable({ systemId }: { systemId?: string }) {
|
||||
const loadTime = Date.now()
|
||||
const [data, setData] = useState<PveVmRecord[] | undefined>(undefined)
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-pve-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<PveVmRecord>("pve_vms")
|
||||
.getList(0, 2000, {
|
||||
fields: "id,name,type,cpu,mem,net,maxcpu,maxmem,uptime,system,updated",
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.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 vmIds = new Set<string>()
|
||||
const newItems: PveVmRecord[] = []
|
||||
for (const item of items) {
|
||||
if (Math.abs(lastUpdated - item.updated) < 70_000) {
|
||||
vmIds.add(item.id)
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
for (const item of curItems ?? []) {
|
||||
if (!vmIds.has(item.id) && lastUpdated - item.updated < 70_000) {
|
||||
newItems.push(item)
|
||||
}
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull pve vms after every system update
|
||||
if (!systemId) {
|
||||
return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// exclude initial load of systems
|
||||
if (Date.now() - loadTime > 500) {
|
||||
fetchData(systemId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// if systemId, fetch pve vms after the system is updated
|
||||
return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
fetchData(systemId)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: data ?? [],
|
||||
columns: pveVmCols.filter((col) => (systemId ? col.id !== "system" : true)),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const vm = row.original
|
||||
const systemName = $allSystemsById.get()[vm.system]?.name ?? ""
|
||||
const id = vm.id ?? ""
|
||||
const name = vm.name ?? ""
|
||||
const type = vm.type ?? ""
|
||||
const searchString = `${systemName} ${id} ${name} ${type}`.toLowerCase()
|
||||
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
return (
|
||||
<Card className="p-6 @container w-full">
|
||||
<CardHeader className="p-0 mb-4">
|
||||
<div className="grid md:flex gap-5 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>All Proxmox VMs</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex">
|
||||
<Trans>CPU is percent of overall host CPU usage.</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative ms-auto w-full max-w-full md:w-64">
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ps-4 pe-10 w-full"
|
||||
/>
|
||||
{globalFilter && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Clear`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 flex items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setGlobalFilter("")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<AllPveTable table={table} rows={rows} colLength={visibleColumns.length} data={data} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const AllPveTable = memo(function AllPveTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
data,
|
||||
}: {
|
||||
table: TableType<PveVmRecord>
|
||||
rows: Row<PveVmRecord>[]
|
||||
colLength: number
|
||||
data: PveVmRecord[] | undefined
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeVm = useRef<PveVmRecord | null>(null)
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const openSheet = (vm: PveVmRecord) => {
|
||||
activeVm.current = vm
|
||||
setSheetOpen(true)
|
||||
}
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<PveTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <PveTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
{data ? (
|
||||
<Trans>No results.</Trans>
|
||||
) : (
|
||||
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<PveVmSheet sheetOpen={sheetOpen} setSheetOpen={setSheetOpen} activeVm={activeVm} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function PveVmSheet({
|
||||
sheetOpen,
|
||||
setSheetOpen,
|
||||
activeVm,
|
||||
}: {
|
||||
sheetOpen: boolean
|
||||
setSheetOpen: (open: boolean) => void
|
||||
activeVm: RefObject<PveVmRecord | null>
|
||||
}) {
|
||||
const vm = activeVm.current
|
||||
if (!vm) return null
|
||||
|
||||
const memFormatted = formatBytes(vm.mem, false, undefined, true)
|
||||
const maxMemFormatted = formatBytes(vm.maxmem, false, undefined, false)
|
||||
const netFormatted = formatBytes(vm.net, true, undefined, false)
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="w-full sm:max-w-120 p-2">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{vm.name}</SheetTitle>
|
||||
<SheetDescription className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Link className="hover:underline" href={getPagePath($router, "system", { id: vm.system })}>
|
||||
{$allSystemsById.get()[vm.system]?.name ?? ""}
|
||||
</Link>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.type}
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
<Trans>Up {formatUptime(vm.uptime)}</Trans>
|
||||
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
|
||||
{vm.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="px-3 pb-3 -mt-2 flex flex-col gap-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>Details</Trans>
|
||||
</h3>
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>CPU Usage</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(vm.cpu, vm.cpu >= 10 ? 1 : 2)}%`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Memory Used</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(memFormatted.value, memFormatted.value >= 10 ? 1 : 2)} ${memFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Network</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(netFormatted.value, netFormatted.value >= 10 ? 1 : 2)} ${netFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>vCPUs</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{vm.maxcpu}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Max Memory</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{`${decimalString(maxMemFormatted.value, maxMemFormatted.value >= 10 ? 1 : 2)} ${maxMemFormatted.unit}`}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">
|
||||
<Trans>Uptime</Trans>
|
||||
</dt>
|
||||
<dd className="tabular-nums">{formatUptime(vm.uptime)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function PveTableHead({ table }: { table: TableType<PveVmRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const PveTableRow = memo(function PveTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
openSheet,
|
||||
}: {
|
||||
row: Row<PveVmRecord>
|
||||
virtualRow: VirtualItem
|
||||
openSheet: (vm: PveVmRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer transition-opacity"
|
||||
onClick={() => openSheet(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0 ps-4.5"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { createRouter } from "@nanostores/router"
|
||||
const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
proxmox: "/proxmox",
|
||||
smart: "/smart",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
|
||||
26
internal/site/src/components/routes/proxmox.tsx
Normal file
26
internal/site/src/components/routes/proxmox.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useMemo } from "react"
|
||||
import PveTable from "@/components/pve-table/pve-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`All Proxmox VMs`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return useMemo(
|
||||
() => (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<PveTable />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
),
|
||||
[]
|
||||
)
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
$containerFilter,
|
||||
$direction,
|
||||
$maxValues,
|
||||
$pveFilter,
|
||||
$systems,
|
||||
$temperatureFilter,
|
||||
$userSettings,
|
||||
@@ -160,6 +161,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||
const [pveData, setPveData] = useState([] as ChartData["containerData"])
|
||||
const temperatureChartRef = useRef<HTMLDivElement>(null)
|
||||
const persistChartTime = useRef(false)
|
||||
const [bottomSpacing, setBottomSpacing] = useState(0)
|
||||
@@ -177,8 +179,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
persistChartTime.current = false
|
||||
setSystemStats([])
|
||||
setContainerData([])
|
||||
setPveData([])
|
||||
setDetails({} as SystemDetailsRecord)
|
||||
$containerFilter.set("")
|
||||
$pveFilter.set("")
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -277,6 +281,10 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
// Share chart config computation for all container charts
|
||||
const containerChartConfigs = useContainerChartConfigs(containerData)
|
||||
|
||||
// PVE chart data and configs
|
||||
const pveSyntheticChartData = useMemo(() => ({ ...chartData, containerData: pveData }), [chartData, pveData])
|
||||
const pveChartConfigs = useContainerChartConfigs(pveData)
|
||||
|
||||
// make container stats for charts
|
||||
const makeContainerData = useCallback((containers: ContainerStatsRecord[]) => {
|
||||
const containerData = [] as ChartData["containerData"]
|
||||
@@ -307,7 +315,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", system, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", system, chartTime),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
getStats<ContainerStatsRecord>("pve_stats", system, chartTime),
|
||||
]).then(([systemStats, containerStats, pveStats]) => {
|
||||
// loading: false
|
||||
setChartLoading(false)
|
||||
|
||||
@@ -334,6 +343,17 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
cache.set(cs_cache_key, containerData)
|
||||
}
|
||||
setContainerData(makeContainerData(containerData))
|
||||
// make new pve stats
|
||||
const ps_cache_key = `${system.id}_${chartTime}_pve_stats`
|
||||
let pveRecords = (cache.get(ps_cache_key) || []) as ContainerStatsRecord[]
|
||||
if (pveStats.status === "fulfilled" && pveStats.value.length) {
|
||||
pveRecords = pveRecords.concat(addEmptyValues(pveRecords, pveStats.value, expectedInterval))
|
||||
if (pveRecords.length > 120) {
|
||||
pveRecords = pveRecords.slice(-100)
|
||||
}
|
||||
cache.set(ps_cache_key, pveRecords)
|
||||
}
|
||||
setPveData(makeContainerData(pveRecords))
|
||||
})
|
||||
}, [system, chartTime])
|
||||
|
||||
@@ -399,6 +419,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const showMax = maxValues && isLongerChart
|
||||
|
||||
const containerFilterBar = containerData.length ? <FilterBar /> : null
|
||||
const pveFilterBar = pveData.length ? <FilterBar store={$pveFilter} /> : null
|
||||
|
||||
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
|
||||
const lastGpus = systemStats.at(-1)?.stats?.g
|
||||
@@ -493,6 +514,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox CPU Usage`}
|
||||
description={t`Average CPU utilization of VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
dataKey="c"
|
||||
chartType={ChartType.CPU}
|
||||
chartConfig={pveChartConfigs.cpu}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
@@ -520,6 +559,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox Memory Usage`}
|
||||
description={t`Memory usage of Proxmox VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
dataKey="m"
|
||||
chartType={ChartType.Memory}
|
||||
chartConfig={pveChartConfigs.memory}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||
<DiskChart chartData={chartData} dataKey="stats.du" diskSize={systemStats.at(-1)?.stats.d ?? NaN} />
|
||||
</ChartCard>
|
||||
@@ -641,6 +698,24 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{pveFilterBar && pveData.length > 0 && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Proxmox Network I/O`}
|
||||
description={t`Network traffic of Proxmox VMs and containers`}
|
||||
cornerEl={pveFilterBar}
|
||||
>
|
||||
<ContainerChart
|
||||
chartData={pveSyntheticChartData}
|
||||
chartType={ChartType.Network}
|
||||
dataKey="n"
|
||||
chartConfig={pveChartConfigs.network}
|
||||
filterStore={$pveFilter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Swap chart */}
|
||||
{(systemStats.at(-1)?.stats.su ?? 0) > 0 && (
|
||||
<ChartCard
|
||||
|
||||
@@ -185,3 +185,12 @@ export function PlugChargingIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// simple-icons (CC0) https://github.com/simple-icons/simple-icons/blob/develop/LICENSE.md
|
||||
export function ProxmoxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" {...props} stroke="currentColor" strokeWidth="1.5" fill="none">
|
||||
<path d="M5 1.8c-1.2.6-1.2.7-.1 1.8l7 7.8c.2 0 8-8.6 8.1-8.8l-.5-.5q-.5-.5-1.7-.5c-1.6-.1-2.2.2-4.1 2.4L12 6 10.4 4 8 1.9c-.8-.4-2.4-.5-3.2 0M1.2 4.4q-1.2.5-1.3.8l3 3.5L5.8 12l-3 3.3L0 18.8c.1.5 1.5 1 2.6 1 1.7 0 2-.2 5.6-4.1l3.2-3.7a74 74 0 0 0-7.1-7.5c-.9-.4-2.2-.5-3-.1m18.5 0q-.7.4-4 4L12.6 12l3.3 3.7c3.5 3.9 3.9 4.2 5.6 4.2 1 0 2.4-.6 2.5-1 0-.2-1.3-1.8-2.9-3.6L18 12l3-3.3c1.6-1.8 3-3.3 2.9-3.5 0-.4-1.4-1-2.5-1q-1 0-1.7.3M8 17l-4 4.4.5.6q.6.4 1.7.4c1.6.1 2.2-.2 4.2-2.5l1.6-1.8 1.7 1.8c2 2.3 2.5 2.6 4 2.5q1.3 0 1.8-.4t.5-.6c0-.2-7.9-8.8-8-8.8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ listenKeys($userSettings, ["chartTime"], ({ chartTime }) => $chartTime.set(chart
|
||||
/** Container chart filter */
|
||||
export const $containerFilter = atom("")
|
||||
|
||||
/** PVE chart filter */
|
||||
export const $pveFilter = atom("")
|
||||
|
||||
/** Temperature chart filter */
|
||||
export const $temperatureFilter = atom("")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as systemsManager from "@/lib/systemsManager.ts"
|
||||
const LoginPage = lazy(() => import("@/components/login/login.tsx"))
|
||||
const Home = lazy(() => import("@/components/routes/home.tsx"))
|
||||
const Containers = lazy(() => import("@/components/routes/containers.tsx"))
|
||||
const Proxmox = lazy(() => import("@/components/routes/proxmox.tsx"))
|
||||
const Smart = lazy(() => import("@/components/routes/smart.tsx"))
|
||||
const SystemDetail = lazy(() => import("@/components/routes/system.tsx"))
|
||||
const CopyToClipboardDialog = lazy(() => import("@/components/copy-to-clipboard.tsx"))
|
||||
@@ -63,6 +64,8 @@ const App = memo(() => {
|
||||
return <SystemDetail id={page.params.id} />
|
||||
} else if (page.route === "containers") {
|
||||
return <Containers />
|
||||
} else if (page.route === "proxmox") {
|
||||
return <Proxmox />
|
||||
} else if (page.route === "smart") {
|
||||
return <Smart />
|
||||
} else if (page.route === "settings") {
|
||||
|
||||
22
internal/site/src/types.d.ts
vendored
22
internal/site/src/types.d.ts
vendored
@@ -275,6 +275,28 @@ export interface ContainerRecord extends RecordModel {
|
||||
updated: number
|
||||
}
|
||||
|
||||
export interface PveVmRecord extends RecordModel {
|
||||
id: string
|
||||
system: string
|
||||
name: string
|
||||
/** "qemu" or "lxc" */
|
||||
type: string
|
||||
/** CPU usage percent (0–100, relative to host) */
|
||||
cpu: number
|
||||
/** Memory used (MB) */
|
||||
mem: number
|
||||
/** Network bandwidth (bytes/s, combined send+recv) */
|
||||
net: number
|
||||
/** Max vCPU count */
|
||||
maxcpu: number
|
||||
/** Max memory (bytes) */
|
||||
maxmem: number
|
||||
/** Uptime (seconds) */
|
||||
uptime: number
|
||||
/** Unix timestamp (ms) */
|
||||
updated: number
|
||||
}
|
||||
|
||||
export type ChartTimes = "1m" | "1h" | "12h" | "24h" | "1w" | "30d"
|
||||
|
||||
export interface ChartTimeData {
|
||||
|
||||
Reference in New Issue
Block a user