This commit is contained in:
henrygd
2026-03-02 14:10:26 -05:00
parent 8f23fff1c9
commit d34ef1ebe9
9 changed files with 625 additions and 8 deletions

View File

@@ -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(agent)
// 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())

145
agent/pve.go Normal file
View File

@@ -0,0 +1,145 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"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
containerStatsMap map[string]*container.Stats // Keeps track of container stats
}
// Returns stats for all running VMs/LXCs
func (pm *pveManager) getPVEStats() ([]*container.Stats, error) {
if pm.client == nil {
return nil, errors.New("PVE client not configured")
}
cluster, err := pm.client.Cluster(context.Background())
if err != nil {
return nil, err
}
resources, err := cluster.Resources(context.Background(), "vm")
if err != nil {
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.containerStatsMap {
if _, exists := containerIds[id]; !exists {
delete(pm.containerStatsMap, id)
}
}
// populate stats
stats := make([]*container.Stats, 0, len(containerIds))
for _, resource := range resources {
if _, exists := containerIds[resource.ID]; !exists {
continue
}
resourceStats, initialized := pm.containerStatsMap[resource.ID]
if !initialized {
resourceStats = &container.Stats{}
pm.containerStatsMap[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.Image = resource.Type
// 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()
sent_delta = float64(total_sent-resourceStats.PrevNet.Sent) / secondsElapsed
recv_delta = float64(total_recv-resourceStats.PrevNet.Recv) / secondsElapsed
}
resourceStats.PrevReadTime = time.Now()
// Update final stats values
resourceStats.Cpu = twoDecimals(100.0 * resource.CPU * float64(resource.MaxCPU) / float64(pm.cpuCount))
resourceStats.Mem = 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
func newPVEManager(_ *Agent) *pveManager {
url, exists := GetEnv("PROXMOX_URL")
if !exists {
url = "https://localhost:8006/api2/json"
}
nodeName, nodeNameExists := GetEnv("PROXMOX_NODE")
tokenID, tokenIDExists := GetEnv("PROXMOX_TOKENID")
secret, secretExists := GetEnv("PROXMOX_SECRET")
// 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"
}
var client *proxmox.Client
if nodeNameExists && tokenIDExists && secretExists {
httpClient := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecureTLS,
},
},
}
client = proxmox.NewClient(url,
proxmox.WithHTTPClient(&httpClient),
proxmox.WithAPIToken(tokenID, secret),
)
} else {
client = nil
}
pveManager := &pveManager{
client: client,
nodeName: nodeName,
containerStatsMap: make(map[string]*container.Stats),
}
// Retrieve node cpu count
if client != nil {
node, err := client.Node(context.Background(), nodeName)
if err != nil {
pveManager.client = nil
} else {
pveManager.cpuCount = node.CPUInfo.CPUs
}
}
return pveManager
}

7
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -175,4 +175,5 @@ type CombinedData struct {
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.Stats `json:"pve,omitempty" cbor:"5,keyasint,omitempty"`
}

View File

@@ -209,6 +209,26 @@ 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 {
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 {
return err
}
}
// add new systemd_stats record
if len(data.SystemdServices) > 0 {
if err := createSystemdStatsRecords(txApp, data.SystemdServices, sys.Id); err != nil {
@@ -331,8 +351,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.Stats, systemId string) error {
if len(data) == 0 {
return nil
}
// shared params for all records
params := dbx.Params{
"system": systemId,
"updated": time.Now().UTC().UnixMilli(),
}
valueStrings := make([]string, 0, len(data))
for i, 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}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
params["name"+suffix] = vm.Name
params["type"+suffix] = vm.Image // "qemu" or "lxc"
params["cpu"+suffix] = vm.Cpu
params["memory"+suffix] = vm.Mem
netBytes := vm.Bandwidth[0] + vm.Bandwidth[1]
if netBytes == 0 {
netBytes = uint64((vm.NetworkSent + vm.NetworkRecv) * 1024 * 1024)
}
params["net"+suffix] = netBytes
}
queryString := fmt.Sprintf(
"INSERT INTO pve_vms (id, system, name, type, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, memory=excluded.memory, net=excluded.net, updated=excluded.updated",
strings.Join(valueStrings, ","),
)
_, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
// getRecord retrieves the system record from the database.
// 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 {

View File

@@ -1685,6 +1685,216 @@ func init() {
"type": "base",
"updateRule": null,
"viewRule": null
},
{
"createRule": null,
"deleteRule": null,
"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": "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": "pve_stats_col001",
"indexes": [
"CREATE INDEX ` + "`" + `idx_pve_stats_sys_type_created` + "`" + ` ON ` + "`" + `pve_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"listRule": "@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": "memory",
"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": "pve_vms_upd001",
"max": null,
"min": null,
"name": "updated",
"onlyInt": true,
"presentable": false,
"required": true,
"system": false,
"type": "number"
}
],
"id": "pve_vms_col0001",
"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
}
]`

View File

@@ -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.Stats {
// Clear global map for reuse
for k := range containerSums {
delete(containerSums, k)
@@ -453,7 +458,7 @@ 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{}
@@ -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()

133
pve-plan.md Normal file
View File

@@ -0,0 +1,133 @@
# Goal
Add Proxmox VE (PVE) stats for VMs and LXCs to Beszel, working similarly to how Docker containers are tracked: collect every minute, show stacked area graphs on the system page for CPU, mem, network, plus a dedicated global table/page showing all VMs/LXCs with current data.
# Instructions
- Reuse container.Stats struct for PVE data transport and storage (compatible with existing averaging functions after minor refactor)
- Store clean VM name (without type) in container.Stats.Name; store Proxmox resource ID (e.g. "qemu/100") in container.Stats.Id field; store type ("qemu" or "lxc") in container.Stats.Image field — both Id and Image are json:"-" / CBOR-only so they don't pollute stored JSON stats
- Add PROXMOX_INSECURE_TLS env var (default true) for TLS verification control
- Filter to only running VMs/LXCs (check resource.Status == "running")
- pve_vms table row ID: use makeStableHashId(systemId, resourceId) — same FNV-32a hash used for systemd services, returns 8 hex chars
- VM type (qemu/lxc) stripped from name, shown as a separate badge column in the PVE table
- PVE page is global (shows VMs/LXCs across all systems, with a System column) — mirrors the containers page
- pve_stats collection structure is identical to container_stats (same fields: id, system, stats JSON, type select, created, updated)
- pve_vms collection needs: id (8-char hex), system (relation), name (text), type (text: "qemu"/"lxc"), cpu (number), memory (number), net (number), updated (number)
Discoveries
- container.Stats struct has Id, Image, Status fields tagged json:"-" but transmitted via CBOR (keys 7, 8, 6 respectively) — perfect for carrying PVE-specific metadata without polluting stored JSON. Id is cbor key 7, Image is cbor key 8.
- makeStableHashId(...strings) already exists in internal/hub/systems/system.go using FNV-32a, returns 8-char hex — reuse this for pve_vms row IDs
- AverageContainerStats() in records.go hardcodes "container_stats" table name — needs to be refactored to accept a collectionName parameter to be reused for pve_stats
- deleteOldSystemStats() in records.go hardcodes [2]string{"system_stats", "container_stats"} — needs pve_stats added
- ContainerChart component reads chartData.containerData directly from the chartData prop — it cannot be reused for PVE without modification. For PVE charts, we need to either modify ContainerChart to accept a custom data prop, or create a PveChart wrapper that passes PVE-shaped chartData. The simplest approach: pass a modified chartData object with containerData replaced by pveData to a reused ContainerChart.
- useContainerChartConfigs(containerData) in hooks.ts is pure and can be called with any ChartData["containerData"]-shaped array — perfectly reusable for PVE.
- $containerFilter atom is in internal/site/src/lib/stores.ts — need to add $pveFilter atom similarly
- ChartData interface in types.d.ts needs a pveData field added alongside containerData
- The getStats() function in system.tsx is generic and reusable for fetching pve_stats
- go-proxmox v0.4.0 is already a direct dependency in go.mod
- Migration files are in internal/migrations/ — only 2 files exist currently; new migration should be named sequentially (e.g., 1_pve_collections.go)
- The ContainerChart component reads const { containerData } = chartData on line 37. The fix is to pass a synthetic chartData that has containerData replaced by pveData when rendering PVE charts.
- createContainerRecords() in system.go checks data.Containers[0].Id != "" before saving — PVE stats use .Id to store resource ID (e.g. "qemu/100"), so that check will work correctly for PVE too
- AverageContainerStats in records.go uses stat.Name as the key for sums map — PVE data stored with clean name (no type suffix) works correctly
- The containers table collection ID is "pbc_1864144027" — need unique IDs for new collections in the migration
- The container_stats collection ID is "juohu4jipgc13v7" — need unique IDs for pve_stats and pve_vms
- The containers table id field pattern is [a-f0-9]{6} min 6, max 12 — for pve_vms we want 8-char hex IDs: set pattern [a-f0-9]{8}, min 8, max 8
- In the migration file, use app.ImportCollectionsByMarshaledJSON with false for upsert (same as existing migration)
- CombinedData struct in system.go uses cbor keys 0-4 for existing fields — PVEStats should be cbor key 5
- For pve\*stats in CreateLongerRecords(), need to add it to the collections array and handle it similarly to container_stats
Accomplished
Planning and file reading phase complete. No files have been written/modified yet.
All relevant source files have been read and fully understood. The complete implementation plan is finalized. Ready to begin writing code.
Files to CREATE
| File | Purpose |
| ------------------------------------------------------------ | -------------------------------------------------------------------- |
| internal/migrations/1_pve_collections.go | New pve_stats + pve_vms DB collections |
| internal/site/src/components/routes/pve.tsx | Global PVE VMs/LXCs page route |
| internal/site/src/components/pve-table/pve-table.tsx | PVE table component (mirrors containers-table) |
| internal/site/src/components/pve-table/pve-table-columns.tsx | Table column defs (Name, System, Type badge, CPU, Mem, Net, Updated) |
Files to MODIFY
| File | Changes needed |
|---|---|
| agent/pve.go | Refactor: clean name (no type suffix), store resourceId in .Id, type in .Image, filter running-only, add PROXMOX*INSECURE_TLS env var, change network tracking to use bytes |
| agent/agent.go | Add pveManager *pveManager field to Agent struct; initialize in NewAgent(); call getPVEStats() in gatherStats(), store in data.PVEStats |
| internal/entities/system/system.go | Add PVEStats []*container.Stats \json:"pve,omitempty" cbor:"5,keyasint,omitempty"\` to CombinedData` struct |
| internal/hub/systems/system.go | In createRecords(): if data.PVEStats non-empty, save pve_stats record + call new createPVEVMRecords() function |
| internal/records/records.go | Refactor AverageContainerStats(db, records) → AverageContainerStats(db, records, collectionName); add pve_stats to CreateLongerRecords() and deleteOldSystemStats(); add deleteOldPVEVMRecords() called from DeleteOldRecords() |
| internal/site/src/components/routes/system.tsx | Add pveData state + fetching (reuse getStats() for pve_stats); add usePVEChartConfigs; add 3 PVE ChartCard+ContainerChart blocks (CPU/Mem/Net) using synthetic chartData with pveData as containerData; add PVE filter bar; reset $pveFilter on unmount |
| internal/site/src/components/router.tsx | Add pve: "/pve" route |
| internal/site/src/components/navbar.tsx | Add PVE nav link (using ServerIcon or BoxesIcon) between Containers and SMART links |
| internal/site/src/lib/stores.ts | Add export const $pveFilter = atom("") |
| internal/site/src/types.d.ts | Add PveStatsRecord interface (same shape as ContainerStatsRecord); add PveVMRecord interface; add pveData to ChartData interface |
Key Implementation Notes
agent/pve.go
// Filter running only: skip if resource.Status != "running"
// Store type without type in name: resourceStats.Name = resource.Name
// Store resource ID: resourceStats.Id = resource.ID (e.g. "qemu/100")
// Store type: resourceStats.Image = resource.Type (e.g. "qemu" or "lxc")
// PROXMOX_INSECURE_TLS: default true, parse "false" to disable
insecureTLS := true
if val, exists := GetEnv("PROXMOX_INSECURE_TLS"); exists {
insecureTLS = val != "false"
}
internal/hub/systems/system.go — createPVEVMRecords
func createPVEVMRecords(app core.App, data []\*container.Stats, systemId string) error {
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}, {:memory%[1]s}, {:net%[1]s}, {:updated})", suffix))
params["id"+suffix] = makeStableHashId(systemId, vm.Id)
params["name"+suffix] = vm.Name
params["type"+suffix] = vm.Image // "qemu" or "lxc"
params["cpu"+suffix] = vm.Cpu
params["memory"+suffix] = vm.Mem
netBytes := vm.Bandwidth[0] + vm.Bandwidth[1]
if netBytes == 0 {
netBytes = uint64((vm.NetworkSent + vm.NetworkRecv) * 1024 \* 1024)
}
params["net"+suffix] = netBytes
}
queryString := fmt.Sprintf(
"INSERT INTO pve*vms (id, system, name, type, cpu, memory, net, updated) VALUES %s ON CONFLICT(id) DO UPDATE SET system=excluded.system, name=excluded.name, type=excluded.type, cpu=excluded.cpu, memory=excluded.memory, net=excluded.net, updated=excluded.updated",
strings.Join(valueStrings, ","),
)
*, err := app.DB().NewQuery(queryString).Bind(params).Execute()
return err
}
system.tsx — PVE chart rendering pattern
// Pass synthetic chartData to ContainerChart so it reads pveData as containerData
const pveSyntheticChartData = useMemo(() => ({...chartData, containerData: pveData}), [chartData, pveData])
// Then:
<ContainerChart chartData={pveSyntheticChartData} dataKey="c" chartType={ChartType.CPU} chartConfig={pveChartConfigs.cpu} />
records.go — AverageContainerStats refactor
// Change signature to accept collectionName:
func (rm \*RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds, collectionName string) []container.Stats
// Change hardcoded query string:
db.NewQuery(fmt.Sprintf("SELECT stats FROM %s WHERE id = {:id}", collectionName)).Bind(queryParams).One(&statsRecord)
// In CreateLongerRecords, update all callers:
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds, collection.Name))
Relevant files / directories
agent/pve.go # Main file to refactor
agent/agent.go # Add pveManager field + initialization
internal/entities/system/system.go # Add PVEStats to CombinedData
internal/entities/container/container.go # container.Stats struct (reused for PVE)
internal/hub/systems/system.go # Hub record creation (createRecords, makeStableHashId)
internal/records/records.go # Longer records + deletion
internal/migrations/0_collections_snapshot_0_18_0_dev_2.go # Reference for collection schema format
internal/migrations/1_pve_collections.go # TO CREATE
internal/site/src/ # Frontend root
internal/site/src/types.d.ts # TypeScript types
internal/site/src/lib/stores.ts # Nanostores atoms
internal/site/src/components/router.tsx # Routes
internal/site/src/components/navbar.tsx # Navigation
internal/site/src/components/routes/system.tsx # System detail page (1074 lines) - add PVE charts
internal/site/src/components/routes/containers.tsx # Reference for pve.tsx structure
internal/site/src/components/routes/pve.tsx # TO CREATE
internal/site/src/components/charts/container-chart.tsx # Reused for PVE charts (reads chartData.containerData)
internal/site/src/components/charts/hooks.ts # useContainerChartConfigs (reused for PVE)
internal/site/src/components/containers-table/containers-table.tsx # Reference for PVE table
internal/site/src/components/containers-table/containers-table-columns.tsx # Reference for PVE columns
internal/site/src/components/pve-table/ # TO CREATE (directory + 2 files)