feat(proxmox): add node-level stats endpoint with streaming support

Add new `/proxmox/stats/{node}` API endpoint for retrieving Proxmox node
statistics in JSON format. The endpoint returns kernel version, CPU
usage/model, memory usage, rootfs usage, uptime, and load averages.

The existing `/proxmox/stats/{node}/{vmid}` endpoint has been corrected `VMStats` to return`text/plain` instead of `application/json`.

Both endpoints support WebSocket streaming for real-time stats updates
with a 1-second poll interval.
This commit is contained in:
yusing
2026-01-25 13:50:37 +08:00
parent fc73803bc1
commit 2335ef0fb1
6 changed files with 424 additions and 21 deletions

View File

@@ -149,7 +149,8 @@ func NewHandler(requireAuth bool) *gin.Engine {
proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl)
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)
proxmox.GET("/stats/:node/:vmid", proxmoxApi.Stats)
proxmox.GET("/stats/:node", proxmoxApi.NodeStats)
proxmox.GET("/stats/:node/:vmid", proxmoxApi.VMStats)
proxmox.POST("/lxc/:node/:vmid/start", proxmoxApi.Start)
proxmox.POST("/lxc/:node/:vmid/stop", proxmoxApi.Stop)
proxmox.POST("/lxc/:node/:vmid/restart", proxmoxApi.Restart)

View File

@@ -2452,12 +2452,9 @@
"operationId": "lxcStop"
}
},
"/proxmox/stats/{node}/{vmid}": {
"/proxmox/stats/{node}": {
"get": {
"description": "Get proxmox stats in format of \"STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O\"",
"consumes": [
"application/json"
],
"description": "Get proxmox node stats in json",
"produces": [
"application/json"
],
@@ -2465,7 +2462,63 @@
"proxmox",
"websocket"
],
"summary": "Get proxmox stats",
"summary": "Get proxmox node stats",
"parameters": [
{
"type": "string",
"description": "Node name",
"name": "node",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Stats output",
"schema": {
"$ref": "#/definitions/proxmox.NodeStats"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"403": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Node not found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
}
},
"x-id": "nodeStats",
"operationId": "nodeStats"
}
},
"/proxmox/stats/{node}/{vmid}": {
"get": {
"description": "Get proxmox VM stats in format of \"STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O\"",
"produces": [
"text/plain"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get proxmox VM stats",
"parameters": [
{
"type": "string",
@@ -2512,8 +2565,8 @@
}
}
},
"x-id": "stats",
"operationId": "stats"
"x-id": "vmStats",
"operationId": "vmStats"
}
},
"/reload": {
@@ -6331,6 +6384,83 @@
"x-nullable": false,
"x-omitempty": false
},
"proxmox.NodeStats": {
"type": "object",
"properties": {
"cpu_model": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"cpu_usage": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"kernel_version": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_avg_15m": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_avg_1m": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"load_avg_5m": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"mem_pct": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"mem_total": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"mem_usage": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"pve_version": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"rootfs_pct": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"rootfs_total": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"rootfs_usage": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
},
"uptime": {
"type": "string",
"x-nullable": false,
"x-omitempty": false
}
},
"x-nullable": false,
"x-omitempty": false
},
"route.Route": {
"type": "object",
"properties": {

View File

@@ -1784,6 +1784,37 @@ definitions:
type: object
netip.Addr:
type: object
proxmox.NodeStats:
properties:
cpu_model:
type: string
cpu_usage:
type: string
kernel_version:
type: string
load_avg_15m:
type: string
load_avg_1m:
type: string
load_avg_5m:
type: string
mem_pct:
type: string
mem_total:
type: string
mem_usage:
type: string
pve_version:
type: string
rootfs_pct:
type: string
rootfs_total:
type: string
rootfs_usage:
type: string
uptime:
type: string
type: object
route.Route:
properties:
access_log:
@@ -3611,11 +3642,46 @@ paths:
tags:
- proxmox
x-id: lxcStop
/proxmox/stats/{node}:
get:
description: Get proxmox node stats in json
parameters:
- description: Node name
in: path
name: node
required: true
type: string
produces:
- application/json
responses:
"200":
description: Stats output
schema:
$ref: '#/definitions/proxmox.NodeStats'
"400":
description: Invalid request
schema:
$ref: '#/definitions/ErrorResponse'
"403":
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Node not found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get proxmox node stats
tags:
- proxmox
- websocket
x-id: nodeStats
/proxmox/stats/{node}/{vmid}:
get:
consumes:
- application/json
description: Get proxmox stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET
description: Get proxmox VM stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET
I/O|BLOCK I/O"
parameters:
- in: path
@@ -3627,7 +3693,7 @@ paths:
required: true
type: integer
produces:
- application/json
- text/plain
responses:
"200":
description: Stats output
@@ -3649,11 +3715,11 @@ paths:
description: Internal server error
schema:
$ref: '#/definitions/ErrorResponse'
summary: Get proxmox stats
summary: Get proxmox VM stats
tags:
- proxmox
- websocket
x-id: stats
x-id: vmStats
/reload:
post:
consumes:

View File

@@ -16,13 +16,73 @@ type StatsRequest struct {
VMID int `uri:"vmid" binding:"required"`
}
// @x-id "stats"
// @BasePath /api/v1
// @Summary Get proxmox stats
// @Description Get proxmox stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
// @x-id "nodeStats"
// @BasePath /api/v1
// @Summary Get proxmox node stats
// @Description Get proxmox node stats in json
// @Tags proxmox,websocket
// @Accept json
// @Produce application/json
// @Param node path string true "Node name"
// @Success 200 {object} proxmox.NodeStats "Stats output"
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
// @Failure 403 {object} apitypes.ErrorResponse "Unauthorized"
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /proxmox/stats/{node} [get]
func NodeStats(c *gin.Context) {
nodeName := c.Param("node")
if nodeName == "" {
c.JSON(http.StatusBadRequest, apitypes.Error("node name is required"))
return
}
node, ok := proxmox.Nodes.Get(nodeName)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
return
}
isWs := httpheaders.IsWebsocket(c.Request.Header)
reader, err := node.NodeStats(c.Request.Context(), isWs)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get stats"))
return
}
defer reader.Close()
if !isWs {
var line [512]byte
n, err := reader.Read(line[:])
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
return
}
c.Data(http.StatusOK, "application/json", line[:n])
return
}
manager, err := websocket.NewManagerWithUpgrade(c)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to upgrade to websocket"))
return
}
defer manager.Close()
writer := manager.NewWriter(websocket.TextMessage)
_, err = io.Copy(writer, reader)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
return
}
}
// @x-id "vmStats"
// @BasePath /api/v1
// @Summary Get proxmox VM stats
// @Description Get proxmox VM stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O"
// @Tags proxmox,websocket
// @Produce text/plain
// @Param path path StatsRequest true "Request"
// @Success 200 string plain "Stats output"
// @Failure 400 {object} apitypes.ErrorResponse "Invalid request"
@@ -30,7 +90,7 @@ type StatsRequest struct {
// @Failure 404 {object} apitypes.ErrorResponse "Node not found"
// @Failure 500 {object} apitypes.ErrorResponse "Internal server error"
// @Router /proxmox/stats/{node}/{vmid} [get]
func Stats(c *gin.Context) {
func VMStats(c *gin.Context) {
var request StatsRequest
if err := c.ShouldBindUri(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))

View File

@@ -32,6 +32,9 @@ type Config struct {
const ResourcePollInterval = 3 * time.Second
// NodeStatsPollInterval controls how often node stats are streamed when streaming is enabled.
const NodeStatsPollInterval = time.Second
func (c *Config) Client() *Client {
if c.client == nil {
panic("proxmox client accessed before init")

View File

@@ -0,0 +1,143 @@
package proxmox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
)
type NodeStats struct {
KernelVersion string `json:"kernel_version"`
PVEVersion string `json:"pve_version"`
CPUUsage string `json:"cpu_usage"`
CPUModel string `json:"cpu_model"`
MemUsage string `json:"mem_usage"`
MemTotal string `json:"mem_total"`
MemPct string `json:"mem_pct"`
RootFSUsage string `json:"rootfs_usage"`
RootFSTotal string `json:"rootfs_total"`
RootFSPct string `json:"rootfs_pct"`
Uptime string `json:"uptime"`
LoadAvg1m string `json:"load_avg_1m"`
LoadAvg5m string `json:"load_avg_5m"`
LoadAvg15m string `json:"load_avg_15m"`
}
// NodeStats streams node stats, like docker stats.
func (n *Node) NodeStats(ctx context.Context, stream bool) (io.ReadCloser, error) {
if !stream {
var buf bytes.Buffer
if err := n.writeNodeStatsLine(ctx, &buf); err != nil {
return nil, err
}
return io.NopCloser(&buf), nil
}
pr, pw := io.Pipe()
go func() {
writeSample := func() error {
return n.writeNodeStatsLine(ctx, pw)
}
// Match `watch` behavior: write immediately, then on each tick.
if err := writeSample(); err != nil {
_ = pw.CloseWithError(err)
return
}
ticker := time.NewTicker(NodeStatsPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_ = pw.CloseWithError(ctx.Err())
return
case <-ticker.C:
if err := writeSample(); err != nil {
_ = pw.CloseWithError(err)
return
}
}
}
}()
return pr, nil
}
func (n *Node) writeNodeStatsLine(ctx context.Context, w io.Writer) error {
// Fetch node status for CPU and memory metrics.
node, err := n.client.Node(ctx, n.name)
if err != nil {
return err
}
cpu := fmt.Sprintf("%.1f%%", node.CPU*100)
memUsage := formatIECBytes(node.Memory.Used)
memTotal := formatIECBytes(node.Memory.Total)
memPct := "0.00%"
if node.Memory.Total > 0 {
memPct = fmt.Sprintf("%.2f%%", float64(node.Memory.Used)/float64(node.Memory.Total)*100)
}
rootFSUsage := formatIECBytes(node.RootFS.Used)
rootFSTotal := formatIECBytes(node.RootFS.Total)
rootFSPct := "0.00%"
if node.RootFS.Total > 0 {
rootFSPct = fmt.Sprintf("%.2f%%", float64(node.RootFS.Used)/float64(node.RootFS.Total)*100)
}
uptime := formatDuration(node.Uptime)
if len(node.LoadAvg) != 3 {
return fmt.Errorf("unexpected load average length: %d, expected 3 (1m, 5m, 15m)", len(node.LoadAvg))
}
// Linux 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z)
// => 6.17.4-1-pve #1 SMP PREEMPT_DYNAMIC PMX 6.17.4-1 (2025-12-03T15:42Z)
kversion, _ := strings.CutPrefix(node.Kversion, "Linux ")
// => 6.17.4-1-pve
kversion, _, _ = strings.Cut(kversion, " ")
nodeStats := NodeStats{
KernelVersion: kversion,
PVEVersion: node.PVEVersion,
CPUUsage: cpu,
CPUModel: node.CPUInfo.Model,
MemUsage: memUsage,
MemTotal: memTotal,
MemPct: memPct,
RootFSUsage: rootFSUsage,
RootFSTotal: rootFSTotal,
RootFSPct: rootFSPct,
Uptime: uptime,
LoadAvg1m: node.LoadAvg[0],
LoadAvg5m: node.LoadAvg[1],
LoadAvg15m: node.LoadAvg[2],
}
err = json.NewEncoder(w).Encode(nodeStats)
return err
}
// formatDuration formats uptime in seconds to a human-readable string.
func formatDuration(seconds uint64) string {
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
}
days := seconds / 86400
hours := (seconds % 86400) / 3600
mins := (seconds % 3600) / 60
if days > 0 {
return fmt.Sprintf("%dd%dh%dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh%dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}