API: Add GET /api/v1/cluster/metrics endpoint #98 #5230

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-09-26 06:36:23 +02:00
parent 9f119a8cfa
commit bc6c34cb2b
5 changed files with 141 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
)
// ClusterMetrics returns lightweight metrics about the cluster.
//
// @Summary temporary cluster metrics (counts only)
// @Id ClusterMetrics
// @Tags Cluster
// @Produce json
// @Success 200 {object} cluster.MetricsResponse
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/cluster/metrics [get]
func ClusterMetrics(router *gin.RouterGroup) {
router.GET("/cluster/metrics", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionView)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
nodes, _ := regy.List()
counts := map[string]int{"total": len(nodes)}
for _, node := range nodes {
role := node.Role
if role == "" {
role = "unknown"
}
counts[role]++
}
c.JSON(http.StatusOK, cluster.MetricsResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: counts,
Time: time.Now().UTC().Format(time.RFC3339),
})
})
}

View File

@@ -26,15 +26,20 @@ func TestClusterPermissions(t *testing.T) {
defer conf.SetAuthMode(config.AuthModePublic) defer conf.SetAuthMode(config.AuthModePublic)
ClusterSummary(router) ClusterSummary(router)
ClusterMetrics(router)
r := PerformRequest(app, http.MethodGet, "/api/v1/cluster") r := PerformRequest(app, http.MethodGet, "/api/v1/cluster")
assert.Equal(t, http.StatusUnauthorized, r.Code) assert.Equal(t, http.StatusUnauthorized, r.Code)
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/metrics")
assert.Equal(t, http.StatusUnauthorized, r.Code)
}) })
t.Run("ForbiddenFromCDN", func(t *testing.T) { t.Run("ForbiddenFromCDN", func(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal conf.Options().NodeRole = cluster.RolePortal
ClusterListNodes(router) ClusterListNodes(router)
ClusterMetrics(router)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/nodes", nil) req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/nodes", nil)
// Mark as CDN request, which Auth() forbids. // Mark as CDN request, which Auth() forbids.
@@ -47,9 +52,13 @@ func TestClusterPermissions(t *testing.T) {
app, router, conf := NewApiTest() app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal conf.Options().NodeRole = cluster.RolePortal
ClusterSummary(router) ClusterSummary(router)
ClusterMetrics(router)
token := AuthenticateAdmin(app, router) token := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token)
assert.Equal(t, http.StatusOK, r.Code) assert.Equal(t, http.StatusOK, r.Code)
r = AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/metrics", token)
assert.Equal(t, http.StatusOK, r.Code)
}) })
// Note: most fixture users have admin role; client-scope test below covers non-admin denial. // Note: most fixture users have admin role; client-scope test below covers non-admin denial.
@@ -77,7 +86,11 @@ func TestClusterPermissions(t *testing.T) {
token := gjson.Get(w.Body.String(), "access_token").String() token := gjson.Get(w.Body.String(), "access_token").String()
ClusterSummary(router) ClusterSummary(router)
ClusterMetrics(router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token) r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster", token)
assert.Equal(t, http.StatusForbidden, r.Code) assert.Equal(t, http.StatusForbidden, r.Code)
r = AuthenticatedRequest(app, http.MethodGet, "/api/v1/cluster/metrics", token)
assert.Equal(t, http.StatusForbidden, r.Code)
}) })
} }

View File

@@ -324,6 +324,26 @@
}, },
"type": "object" "type": "object"
}, },
"cluster.MetricsResponse": {
"properties": {
"clusterCidr": {
"type": "string"
},
"nodes": {
"additionalProperties": {
"type": "integer"
},
"type": "object"
},
"time": {
"type": "string"
},
"uuid": {
"type": "string"
}
},
"type": "object"
},
"cluster.Node": { "cluster.Node": {
"properties": { "properties": {
"advertiseUrl": { "advertiseUrl": {
@@ -6409,6 +6429,44 @@
] ]
} }
}, },
"/api/v1/cluster/metrics": {
"get": {
"operationId": "ClusterMetrics",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/cluster.MetricsResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"403": {
"description": "Forbidden",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
},
"429": {
"description": "Too Many Requests",
"schema": {
"$ref": "#/definitions/i18n.Response"
}
}
},
"summary": "temporary cluster metrics (counts only)",
"tags": [
"Cluster"
]
}
},
"/api/v1/cluster/nodes": { "/api/v1/cluster/nodes": {
"get": { "get": {
"operationId": "ClusterListNodes", "operationId": "ClusterListNodes",

View File

@@ -201,6 +201,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.ClusterUpdateNode(APIv1) api.ClusterUpdateNode(APIv1)
api.ClusterDeleteNode(APIv1) api.ClusterDeleteNode(APIv1)
api.ClusterSummary(APIv1) api.ClusterSummary(APIv1)
api.ClusterMetrics(APIv1)
api.ClusterHealth(APIv1) api.ClusterHealth(APIv1)
// Technical Endpoints. // Technical Endpoints.

View File

@@ -42,6 +42,15 @@ type SummaryResponse struct {
Time string `json:"time"` Time string `json:"time"`
} }
// MetricsResponse is the response type for GET /api/v1/cluster/metrics.
// swagger:model MetricsResponse
type MetricsResponse struct {
UUID string `json:"uuid"`
ClusterCIDR string `json:"clusterCidr,omitempty"`
Nodes map[string]int `json:"nodes"`
Time string `json:"time"`
}
// RegisterSecrets contains newly issued or rotated node secrets. // RegisterSecrets contains newly issued or rotated node secrets.
// swagger:model RegisterSecrets // swagger:model RegisterSecrets
type RegisterSecrets struct { type RegisterSecrets struct {