diff --git a/agent/agent.go b/agent/agent.go index 7c30d926..3333ea53 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -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()) diff --git a/agent/pve.go b/agent/pve.go new file mode 100644 index 00000000..74969608 --- /dev/null +++ b/agent/pve.go @@ -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 +} diff --git a/go.mod b/go.mod index 02f6dac5..0a7998dd 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index e80114e9..70e2816c 100644 --- a/go.sum +++ b/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= diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 2a5f50f6..72e2af86 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -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"` } diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 109db35a..c8ece9c6 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -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 { diff --git a/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go similarity index 89% rename from internal/migrations/0_collections_snapshot_0_18_0_dev_2.go rename to internal/migrations/0_collections_snapshot_0_19_0_dev_1.go index 79800649..4cf44c82 100644 --- a/internal/migrations/0_collections_snapshot_0_18_0_dev_2.go +++ b/internal/migrations/0_collections_snapshot_0_19_0_dev_1.go @@ -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 } ]` diff --git a/internal/records/records.go b/internal/records/records.go index b1deb88a..4ba2388f 100644 --- a/internal/records/records.go +++ b/internal/records/records.go @@ -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() diff --git a/pve-plan.md b/pve-plan.md new file mode 100644 index 00000000..01af64d8 --- /dev/null +++ b/pve-plan.md @@ -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: + + + 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)