feat(proxmox): add LXC container stats endpoint with streaming support

Implement a new API endpoint to retrieve real-time statistics for Proxmox
LXC containers, similar to `docker stats` functionality.

Changes:
- Add `GET /api/v1/proxmox/stats/:node/:vmid` endpoint with HTTP and WebSocket support
- Implement resource polling loop to cache VM metadata every 3 seconds
- Create `LXCStats()` method with streaming (websocket) and single-shot modes
- Format output as: STATUS|CPU%|MEM USAGE/LIMIT|MEM%|NET I/O|BLOCK I/O
- Add `GetResource()` method for efficient VM resource lookup by kind and ID
- Fix task creation bug using correct client reference

Example response:
  running|31.1%|9.6GiB/20GiB|48.87%|4.7GiB/3.3GiB|25GiB/36GiB
This commit is contained in:
yusing
2026-01-25 01:37:13 +08:00
parent c191676565
commit b4646b665f
10 changed files with 477 additions and 11 deletions

View File

@@ -147,6 +147,7 @@ func NewHandler(requireAuth bool) *gin.Engine {
proxmox := v1.Group("/proxmox")
{
proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl)
proxmox.GET("/stats/:node/:vmid", proxmoxApi.Stats)
}
}

View File

@@ -218,6 +218,12 @@
"$ref": "#/definitions/ErrorResponse"
}
},
"404": {
"description": "Node not found",
"schema": {
"$ref": "#/definitions/ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -229,6 +235,70 @@
"operationId": "journalctl"
}
},
"/api/v1/proxmox/stats/{node}/{vmid}": {
"get": {
"description": "Get proxmox stats in format of \"STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET I/O|BLOCK I/O\"",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get proxmox stats",
"parameters": [
{
"type": "string",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"name": "vmid",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Stats output",
"schema": {
"type": "string"
}
},
"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": "stats",
"operationId": "stats"
}
},
"/auth/callback": {
"post": {
"description": "Handles the callback from the provider after successful authentication",

View File

@@ -2119,6 +2119,10 @@ paths:
description: Unauthorized
schema:
$ref: '#/definitions/ErrorResponse'
"404":
description: Node not found
schema:
$ref: '#/definitions/ErrorResponse'
"500":
description: Internal server error
schema:
@@ -2128,6 +2132,49 @@ paths:
- proxmox
- websocket
x-id: journalctl
/api/v1/proxmox/stats/{node}/{vmid}:
get:
consumes:
- application/json
description: Get proxmox stats in format of "STATUS|CPU%%|MEM USAGE/LIMIT|MEM%%|NET
I/O|BLOCK I/O"
parameters:
- in: path
name: node
required: true
type: string
- in: path
name: vmid
required: true
type: integer
produces:
- application/json
responses:
"200":
description: Stats output
schema:
type: string
"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 stats
tags:
- proxmox
- websocket
x-id: stats
/auth/callback:
post:
description: Handles the callback from the provider after successful authentication

View File

@@ -27,6 +27,7 @@ type JournalctlRequest struct {
// @Success 200 string plain "Journalctl 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 /api/v1/proxmox/journalctl/{node}/{vmid}/{service} [get]
func Journalctl(c *gin.Context) {

View File

@@ -0,0 +1,79 @@
package proxmoxapi
import (
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/proxmox"
"github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/http/httpheaders"
"github.com/yusing/goutils/http/websocket"
)
type StatsRequest struct {
Node string `uri:"node" binding:"required"`
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"
// @Tags proxmox,websocket
// @Accept json
// @Produce application/json
// @Param path path StatsRequest true "Request"
// @Success 200 string plain "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 /api/v1/proxmox/stats/{node}/{vmid} [get]
func Stats(c *gin.Context) {
var request StatsRequest
if err := c.ShouldBindUri(&request); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
}
node, ok := proxmox.Nodes.Get(request.Node)
if !ok {
c.JSON(http.StatusNotFound, apitypes.Error("node not found"))
return
}
isWs := httpheaders.IsWebsocket(c.Request.Header)
reader, err := node.LXCStats(c.Request.Context(), request.VMID, isWs)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get stats"))
return
}
defer reader.Close()
if !isWs {
var line [128]byte
n, err := reader.Read(line[:])
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to copy stats"))
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", 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
}
}