feat(proxmox): add journalctl endpoint without service; add limit parameter

Added new Proxmox journalctl endpoint `/journalctl/:node/:vmid` for viewing all
journalctl output without requiring a service name. Made the service parameter
optional across both endpoints.

Introduced configurable `limit` query parameter (1-1000, default 100) to both
proxmox journalctl and docker logs APIs, replacing hardcoded 100-line tail.

Added container status check in LXCCommand to prevent command execution on
stopped containers, returning a clear status message instead.

Refactored route validation to use pre-fetched IPs and improved References()
method for proxmox routes with better alias handling.
This commit is contained in:
yusing
2026-01-25 12:03:50 +08:00
parent 22582cd32f
commit b8c61c37dc
7 changed files with 185 additions and 24 deletions

View File

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

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/pkg/stdcopy"
@@ -22,6 +23,7 @@ type LogsQueryParams struct {
Since string `form:"from"`
Until string `form:"to"`
Levels string `form:"levels"`
Limit int `form:"limit,default=100" binding:"omitempty,min=1,max=1000"`
} // @name LogsQueryParams
// @x-id "logs"
@@ -34,9 +36,10 @@ type LogsQueryParams struct {
// @Param id path string true "container id"
// @Param stdout query bool false "show stdout"
// @Param stderr query bool false "show stderr"
// @Param from query string false "from timestamp"
// @Param to query string false "to timestamp"
// @Param from query string false "from timestamp"
// @Param to query string false "to timestamp"
// @Param levels query string false "levels"
// @Param limit query int false "limit"
// @Success 200
// @Failure 400 {object} apitypes.ErrorResponse
// @Failure 403 {object} apitypes.ErrorResponse
@@ -77,7 +80,7 @@ func Logs(c *gin.Context) {
Until: queryParams.Until,
Timestamps: true,
Follow: true,
Tail: "100",
Tail: strconv.Itoa(queryParams.Limit),
}
if queryParams.Levels != "" {
opts.Details = true

View File

@@ -165,6 +165,76 @@
"operationId": "verify"
}
},
"/api/v1/proxmox/journalctl/{node}/{vmid}": {
"get": {
"description": "Get journalctl output",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"proxmox",
"websocket"
],
"summary": "Get journalctl output",
"parameters": [
{
"type": "string",
"name": "node",
"in": "path",
"required": true
},
{
"type": "integer",
"name": "vmid",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "limit",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "Journalctl 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": "journalctl",
"operationId": "journalctl"
}
},
"/api/v1/proxmox/journalctl/{node}/{vmid}/{service}": {
"get": {
"description": "Get journalctl output",
@@ -189,14 +259,19 @@
{
"type": "string",
"name": "service",
"in": "path",
"required": true
"in": "path"
},
{
"type": "integer",
"name": "vmid",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "limit",
"name": "limit",
"in": "query"
}
],
"responses": {
@@ -703,6 +778,12 @@
"description": "levels",
"name": "levels",
"in": "query"
},
{
"type": "integer",
"description": "limit",
"name": "limit",
"in": "query"
}
],
"responses": {

View File

@@ -2088,6 +2088,52 @@ paths:
tags:
- agent
x-id: verify
/api/v1/proxmox/journalctl/{node}/{vmid}:
get:
consumes:
- application/json
description: Get journalctl output
parameters:
- in: path
name: node
required: true
type: string
- in: path
name: vmid
required: true
type: integer
- description: limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: Journalctl 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 journalctl output
tags:
- proxmox
- websocket
x-id: journalctl
/api/v1/proxmox/journalctl/{node}/{vmid}/{service}:
get:
consumes:
@@ -2100,12 +2146,15 @@ paths:
type: string
- in: path
name: service
required: true
type: string
- in: path
name: vmid
required: true
type: integer
- description: limit
in: query
name: limit
type: integer
produces:
- application/json
responses:
@@ -2438,6 +2487,10 @@ paths:
in: query
name: levels
type: string
- description: limit
in: query
name: limit
type: integer
produces:
- application/json
responses:

View File

@@ -13,7 +13,8 @@ import (
type JournalctlRequest struct {
Node string `uri:"node" binding:"required"`
VMID int `uri:"vmid" binding:"required"`
Service string `uri:"service" binding:"required"`
Service string `uri:"service"`
Limit int `query:"limit" binding:"omitempty,min=1,max=1000"`
}
// @x-id "journalctl"
@@ -24,12 +25,14 @@ type JournalctlRequest struct {
// @Accept json
// @Produce application/json
// @Param path path JournalctlRequest true "Request"
// @Param limit query int false "limit"
// @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]
// @Router /api/v1/proxmox/journalctl/{node}/{vmid} [get]
// @Router /api/v1/proxmox/journalctl/{node}/{vmid}/{service} [get]
func Journalctl(c *gin.Context) {
var request JournalctlRequest
if err := c.ShouldBindUri(&request); err != nil {
@@ -50,7 +53,7 @@ func Journalctl(c *gin.Context) {
}
defer manager.Close()
reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service)
reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service, request.Limit)
if err != nil {
c.Error(apitypes.InternalServerError(err, "failed to get journalctl output"))
return

View File

@@ -23,6 +23,15 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea
return nil, fmt.Errorf("failed to get node: %w", err)
}
lxc, err := node.Container(ctx, vmid)
if err != nil {
return nil, fmt.Errorf("failed to get container: %w", err)
}
if lxc.Status != "running" {
return io.NopCloser(bytes.NewReader(fmt.Appendf(nil, "container %d is not running, status: %s\n", vmid, lxc.Status))), nil
}
term, err := node.TermProxy(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get term proxy: %w", err)
@@ -117,6 +126,16 @@ func (n *Node) LXCCommand(ctx context.Context, vmid int, command string) (io.Rea
}
// LXCJournalctl streams journalctl output for the given service.
func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string) (io.ReadCloser, error) {
return n.LXCCommand(ctx, vmid, fmt.Sprintf("journalctl -u %q -f", service))
//
// If service is not empty, it will be used to filter the output by service.
// If limit is greater than 0, it will be used to limit the number of lines of output.
func (n *Node) LXCJournalctl(ctx context.Context, vmid int, service string, limit int) (io.ReadCloser, error) {
command := "journalctl -f"
if service != "" {
command = fmt.Sprintf("journalctl -u %q -f", service)
}
if limit > 0 {
command = fmt.Sprintf("%s -n %d", command, limit)
}
return n.LXCCommand(ctx, vmid, command)
}

View File

@@ -219,11 +219,7 @@ func (r *Route) validate() gperr.Error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ips, err := node.LXCGetIPs(ctx, vmid)
if err != nil {
return gperr.Errorf("failed to get ip addresses of vmid %d: %w", vmid, err)
}
ips := res.IPs
if len(ips) == 0 {
return gperr.Multiline().
Addf("no ip addresses found for %s", containerName).
@@ -345,10 +341,9 @@ func (r *Route) validate() gperr.Error {
// reverse lookup resource by ip address, hostname or alias
if resource != nil {
r.Proxmox = &proxmox.NodeConfig{
Node: resource.Node,
VMID: int(resource.VMID),
VMName: resource.Name,
Service: r.Alias,
Node: resource.Node,
VMID: int(resource.VMID),
VMName: resource.Name,
}
log.Info().
Str("node", resource.Node).
@@ -535,17 +530,23 @@ func (r *Route) References() []string {
}
if r.Container != nil {
if r.Container.ContainerName != r.Alias {
if r.Container.ContainerName != aliasRef {
return []string{r.Container.ContainerName, aliasRef, r.Container.Image.Name, r.Container.Image.Author}
}
return []string{r.Container.Image.Name, aliasRef, r.Container.Image.Author}
}
if r.Proxmox != nil {
if r.Proxmox.VMName != r.Alias {
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Service}
if r.Proxmox.Service != "" && r.Proxmox.Service != aliasRef {
if r.Proxmox.VMName != aliasRef {
return []string{r.Proxmox.VMName, aliasRef, r.Proxmox.Service}
}
return []string{r.Proxmox.Service, aliasRef}
} else {
if r.Proxmox.VMName != aliasRef {
return []string{r.Proxmox.VMName, aliasRef}
}
}
return []string{r.Proxmox.Service, aliasRef}
}
return []string{aliasRef}
}