From cb8f405e7679472f29f3dcd686a0300c97cf2462 Mon Sep 17 00:00:00 2001 From: yusing Date: Sun, 25 Jan 2026 13:50:37 +0800 Subject: [PATCH] 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. --- internal/api/handler.go | 3 +- internal/api/v1/docs/swagger.json | 146 ++++++++++++++++++++++++++++-- internal/api/v1/docs/swagger.yaml | 78 ++++++++++++++-- internal/api/v1/proxmox/stats.go | 72 +++++++++++++-- internal/proxmox/config.go | 3 + internal/proxmox/node_stats.go | 143 +++++++++++++++++++++++++++++ 6 files changed, 424 insertions(+), 21 deletions(-) create mode 100644 internal/proxmox/node_stats.go diff --git a/internal/api/handler.go b/internal/api/handler.go index 15bab19f..6e65e7ad 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -145,7 +145,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) diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index c8b56c46..10c8b247 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -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": { diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index dbfe44ef..9e185581 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -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: diff --git a/internal/api/v1/proxmox/stats.go b/internal/api/v1/proxmox/stats.go index e016770b..0220b628 100644 --- a/internal/api/v1/proxmox/stats.go +++ b/internal/api/v1/proxmox/stats.go @@ -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)) diff --git a/internal/proxmox/config.go b/internal/proxmox/config.go index c750f228..52b250cf 100644 --- a/internal/proxmox/config.go +++ b/internal/proxmox/config.go @@ -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") diff --git a/internal/proxmox/node_stats.go b/internal/proxmox/node_stats.go new file mode 100644 index 00000000..c3de3aa1 --- /dev/null +++ b/internal/proxmox/node_stats.go @@ -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) +}