Files
portainer/api/http/handler/endpoints/endpoint_summary_counts.go

247 lines
7.0 KiB
Go

package endpoints
import (
"net/http"
"sort"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
"golang.org/x/mod/semver"
)
type groupCount struct {
GroupID int `json:"groupID"`
GroupName string `json:"groupName"`
Count int `json:"count"`
}
type platformCounts struct {
Docker int `json:"docker"`
Kubernetes int `json:"kubernetes"`
Azure int `json:"azure"`
Podman int `json:"podman"`
}
type healthCounts struct {
Down int `json:"down"`
Outdated int `json:"outdated"`
Up int `json:"up"`
Heartbeat int `json:"heartbeat"`
}
type EnvironmentSummaryCountsResponse struct {
Total int `json:"total"`
Up int `json:"up"`
Down int `json:"down"`
Outdated int `json:"outdated"`
Unassigned int `json:"unassigned"`
ByGroup []groupCount `json:"byGroup"`
ByPlatformType platformCounts `json:"byPlatformType"`
ByHealth healthCounts `json:"byHealth"`
}
const UnassignedGroupID = portainer.EndpointGroupID(1)
// @id EndpointSummaryCounts
// @summary Get environment summary counts
// @description Returns counts of environments by status (up, down) and ungrouped environments (unassigned), plus breakdowns by group, type, and health.
// @description **Access policy**: restricted
// @tags endpoints
// @security ApiKeyAuth
// @security jwt
// @produce json
// @success 200 {object} EnvironmentSummaryCountsResponse "Environment summary counts"
// @failure 500 "Server error"
// @router /endpoints/summary [get]
func (handler *Handler) endpointSummaryCounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var endpointGroups []portainer.EndpointGroup
var endpoints []portainer.Endpoint
var settings *portainer.Settings
if err := handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
var err error
endpointGroups, err = tx.EndpointGroup().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
}
endpoints, err = tx.Endpoint().Endpoints()
if err != nil {
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
}
settings, err = tx.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
return nil
}); err != nil {
return response.TxErrorResponse(err)
}
// Refresh LastCheckInDate from the in-memory heartbeats map. Edge agents
// use ETag-based response caching: cache hits update the map but do not
// write LastCheckInDate back to the database, so the tx value grows stale.
// The tx path cannot access the in-memory map; this non-tx access is
// intentional.
endpointSvc := handler.DataStore.Endpoint()
for i := range endpoints {
if t, ok := endpointSvc.Heartbeat(endpoints[i].ID); ok {
endpoints[i].LastCheckInDate = t
}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
trustedEndpoints := make([]portainer.Endpoint, 0, len(filteredEndpoints))
for i := range filteredEndpoints {
ep := &filteredEndpoints[i]
if endpointutils.IsEdgeEndpoint(ep) && !ep.UserTrusted {
continue
}
trustedEndpoints = append(trustedEndpoints, filteredEndpoints[i])
}
counts := EnvironmentSummaryCountsResponse{
Total: len(trustedEndpoints),
}
groupCounts := make(map[portainer.EndpointGroupID]int)
platformCounts := platformCounts{}
healthCounts := healthCounts{}
for i := range trustedEndpoints {
endpoint := &trustedEndpoints[i]
switch endpointutils.EndpointPlatformType(endpoint) {
case portainer.DockerPlatformType:
platformCounts.Docker++
case portainer.KubernetesPlatformType:
platformCounts.Kubernetes++
case portainer.AzurePlatformType:
platformCounts.Azure++
case portainer.PodmanPlatformType:
platformCounts.Podman++
case portainer.UnknownPlatformType:
log.Error().Int("endpoint_id", int(endpoint.ID)).Msg("Unknown platform type")
}
groupCounts[endpoint.GroupID]++
if endpoint.GroupID == UnassignedGroupID {
counts.Unassigned++
}
outdated := isOutdated(endpoint)
status := resolveEndpointStatus(endpoint, settings)
if outdated {
counts.Outdated++
healthCounts.Outdated++
}
switch status {
case statusHeartbeat:
healthCounts.Heartbeat++
healthCounts.Up++
counts.Up++
case statusUp:
healthCounts.Up++
counts.Up++
case statusDown:
healthCounts.Down++
counts.Down++
}
}
counts.ByGroup = parseGroupCounts(groupCounts, endpointGroups)
counts.ByPlatformType = platformCounts
counts.ByHealth = healthCounts
return response.JSON(w, counts)
}
// iota order overlaps with portainer.EndpointStatus (Up=1, Down=2) so non-edge
// endpoints can pass their Status straight through. statusHeartbeat (0) is
// edge-only.
const (
statusHeartbeat = iota
statusUp
statusDown
)
func resolveEndpointStatus(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
if endpointutils.IsEdgeEndpoint(endpoint) {
if endpointutils.GetHeartbeatStatus(endpoint, settings) {
return statusHeartbeat
}
return statusDown
}
return int(endpoint.Status)
}
func parseGroupCounts(counts map[portainer.EndpointGroupID]int, endpointGroups []portainer.EndpointGroup) []groupCount {
parsedGroupCounts := []groupCount{}
// Build group name lookup
groupNameByID := make(map[portainer.EndpointGroupID]string, len(endpointGroups))
for _, g := range endpointGroups {
groupNameByID[g.ID] = g.Name
}
for groupID, count := range counts {
parsedGroupCounts = append(parsedGroupCounts,
groupCount{
GroupID: int(groupID),
GroupName: groupNameByID[groupID],
Count: count,
})
}
sort.Slice(parsedGroupCounts, func(i, j int) bool {
return parsedGroupCounts[i].GroupID < parsedGroupCounts[j].GroupID
})
return parsedGroupCounts
}
// canonicalizeSemver ensures v has a "v" prefix as required by golang.org/x/mod/semver.
func canonicalizeSemver(v string) string {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "v") {
return v
}
return "v" + v
}
func isOutdated(endpoint *portainer.Endpoint) bool {
if !endpointutils.IsAgentEndpoint(endpoint) {
return false
}
if endpoint.Agent.Version == "" {
edgeHasCheckedInWithoutVersion := endpointutils.IsEdgeEndpoint(endpoint) && endpoint.LastCheckInDate > 0
return edgeHasCheckedInWithoutVersion
}
latestVersion := canonicalizeSemver(portainer.APIVersion)
agentVersion := canonicalizeSemver(endpoint.Agent.Version)
return semver.Compare(agentVersion, latestVersion) < 0
}