diff --git a/api/http/handler/status/handler.go b/api/http/handler/status/handler.go index b0cd04bab..b54b339b0 100644 --- a/api/http/handler/status/handler.go +++ b/api/http/handler/status/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/http/security" ) @@ -13,21 +14,28 @@ import ( // Handler is the HTTP handler used to handle status operations. type Handler struct { *mux.Router - Status *portainer.Status + status *portainer.Status + dataStore dataservices.DataStore demoService *demo.Service } // NewHandler creates a handler to manage status operations. -func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status, demoService *demo.Service) *Handler { +func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status, demoService *demo.Service, dataStore dataservices.DataStore) *Handler { h := &Handler{ Router: mux.NewRouter(), - Status: status, + dataStore: dataStore, demoService: demoService, + status: status, } + h.Handle("/status", bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet) h.Handle("/status/version", bouncer.AuthenticatedAccess(http.HandlerFunc(h.version))).Methods(http.MethodGet) + h.Handle("/status/nodes", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCount))).Methods(http.MethodGet) + h.Handle("/status/system", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusSystem))).Methods(http.MethodGet) return h } diff --git a/api/http/handler/status/status_inspect.go b/api/http/handler/status/status_inspect.go index bda7df64a..1cfd13d55 100644 --- a/api/http/handler/status/status_inspect.go +++ b/api/http/handler/status/status_inspect.go @@ -24,7 +24,7 @@ type status struct { // @router /status [get] func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { return response.JSON(w, &status{ - Status: handler.Status, + Status: handler.status, DemoEnvironment: handler.demoService.Details(), }) } diff --git a/api/http/handler/status/status_nodes_count.go b/api/http/handler/status/status_nodes_count.go new file mode 100644 index 000000000..f56bcc84a --- /dev/null +++ b/api/http/handler/status/status_nodes_count.go @@ -0,0 +1,42 @@ +package status + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + statusutil "github.com/portainer/portainer/api/internal/nodes" + "github.com/portainer/portainer/api/internal/snapshot" +) + +type nodesCountResponse struct { + Nodes int `json:"nodes"` +} + +// @id statusNodesCount +// @summary Retrieve the count of nodes +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags status +// @produce json +// @success 200 {object} nodesCountResponse "Success" +// @failure 500 "Server error" +// @router /status/nodes [get] +func (handler *Handler) statusNodesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoints, err := handler.dataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Failed to get environment list", err) + } + + for i := range endpoints { + err = snapshot.FillSnapshotData(handler.dataStore, &endpoints[i]) + if err != nil { + return httperror.InternalServerError("Unable to add snapshot data", err) + } + } + + nodes := statusutil.NodesCount(endpoints) + + return response.JSON(w, &nodesCountResponse{Nodes: nodes}) +} diff --git a/api/http/handler/status/status_system.go b/api/http/handler/status/status_system.go new file mode 100644 index 000000000..5438ce9a8 --- /dev/null +++ b/api/http/handler/status/status_system.go @@ -0,0 +1,60 @@ +package status + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/platform" +) + +type systemInfoResponse struct { + Platform platform.ContainerPlatform `json:"platform"` + EdgeAgents int `json:"edgeAgents"` + EdgeDevices int `json:"edgeDevices"` + Agents int `json:"agents"` +} + +// @id statusSystem +// @summary Retrieve system info +// @description **Access policy**: authenticated +// @security ApiKeyAuth +// @security jwt +// @tags status +// @produce json +// @success 200 {object} systemInfoResponse "Success" +// @failure 500 "Server error" +// @router /status/system [get] +func (handler *Handler) statusSystem(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + environments, err := handler.dataStore.Endpoint().Endpoints() + if err != nil { + return httperror.InternalServerError("Failed to get environment list", err) + } + + agents := 0 + edgeAgents := 0 + edgeDevices := 0 + + for _, environment := range environments { + if endpointutils.IsAgentEndpoint(&environment) { + agents++ + } + + if endpointutils.IsEdgeEndpoint(&environment) { + edgeAgents++ + } + + if environment.IsEdgeDevice { + edgeDevices++ + } + + } + + return response.JSON(w, &systemInfoResponse{ + EdgeAgents: edgeAgents, + EdgeDevices: edgeDevices, + Agents: agents, + Platform: platform.DetermineContainerPlatform(), + }) +} diff --git a/api/http/server.go b/api/http/server.go index 7937162bc..3c5fcb606 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -252,7 +252,7 @@ func (server *Server) Start() error { var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) teamMembershipHandler.DataStore = server.DataStore - var statusHandler = status.NewHandler(requestBouncer, server.Status, server.DemoService) + var statusHandler = status.NewHandler(requestBouncer, server.Status, server.DemoService, server.DataStore) var templatesHandler = templates.NewHandler(requestBouncer) templatesHandler.DataStore = server.DataStore diff --git a/api/internal/nodes/nodes.go b/api/internal/nodes/nodes.go new file mode 100644 index 000000000..1e061cd7d --- /dev/null +++ b/api/internal/nodes/nodes.go @@ -0,0 +1,35 @@ +package status + +import ( + portainer "github.com/portainer/portainer/api" +) + +// NodesCount returns the total node number of all environments +func NodesCount(endpoints []portainer.Endpoint) int { + nodes := 0 + for _, env := range endpoints { + nodes += countNodes(&env) + } + + return nodes +} + +func countNodes(endpoint *portainer.Endpoint) int { + if len(endpoint.Snapshots) == 1 { + return max(endpoint.Snapshots[0].NodeCount, 1) + } + + if len(endpoint.Kubernetes.Snapshots) == 1 { + return max(endpoint.Kubernetes.Snapshots[0].NodeCount, 1) + } + + return 1 +} + +func max(a, b int) int { + if a > b { + return a + } + + return b +} diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go index 9a4f6c8d5..a4a8fa939 100644 --- a/api/internal/snapshot/snapshot.go +++ b/api/internal/snapshot/snapshot.go @@ -124,8 +124,12 @@ func (service *Service) Create(snapshot portainer.Snapshot) error { } func (service *Service) FillSnapshotData(endpoint *portainer.Endpoint) error { - snapshot, err := service.dataStore.Snapshot().Snapshot(endpoint.ID) - if service.dataStore.IsErrObjectNotFound(err) { + return FillSnapshotData(service.dataStore, endpoint) +} + +func FillSnapshotData(dataStore dataservices.DataStore, endpoint *portainer.Endpoint) error { + snapshot, err := dataStore.Snapshot().Snapshot(endpoint.ID) + if dataStore.IsErrObjectNotFound(err) { endpoint.Snapshots = []portainer.DockerSnapshot{} endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} diff --git a/api/platform/platform.go b/api/platform/platform.go new file mode 100644 index 000000000..0a87938e5 --- /dev/null +++ b/api/platform/platform.go @@ -0,0 +1,44 @@ +package platform + +import "os" + +const ( + PodmanMode = "PODMAN" + KubernetesServiceHost = "KUBERNETES_SERVICE_HOST" + NomadJobName = "NOMAD_JOB_NAME" +) + +// ContainerPlatform represent the platform on which the container is running (Docker, Kubernetes, Nomad) +type ContainerPlatform string + +const ( + // PlatformDocker represent the Docker platform (Standalone/Swarm) + PlatformDocker = ContainerPlatform("Docker") + // PlatformKubernetes represent the Kubernetes platform + PlatformKubernetes = ContainerPlatform("Kubernetes") + // PlatformPodman represent the Podman platform (Standalone) + PlatformPodman = ContainerPlatform("Podman") + // PlatformNomad represent the Nomad platform (Standalone) + PlatformNomad = ContainerPlatform("Nomad") +) + +// DetermineContainerPlatform will check for the existence of the PODMAN_MODE +// or KUBERNETES_SERVICE_HOST environment variable to determine if +// the container is running on Podman or inside the Kubernetes platform. +// Defaults to Docker otherwise. +func DetermineContainerPlatform() ContainerPlatform { + podmanModeEnvVar := os.Getenv(PodmanMode) + if podmanModeEnvVar == "1" { + return PlatformPodman + } + serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) + if serviceHostKubernetesEnvVar != "" { + return PlatformKubernetes + } + nomadJobName := os.Getenv(NomadJobName) + if nomadJobName != "" { + return PlatformNomad + } + + return PlatformDocker +} diff --git a/api/portainer.go b/api/portainer.go index b418052b4..865655bab 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1499,10 +1499,12 @@ const ( ) const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate" +const FeatureFlagBEUpgrade = "beUpgrade" // List of supported features var SupportedFeatureFlags = []Feature{ FeatureFlagEdgeRemoteUpdate, + FeatureFlagBEUpgrade, } const ( diff --git a/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts b/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts index 32e1dfe4a..0835864a5 100644 --- a/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts +++ b/app/react/portainer/feature-flags/useRedirectFeatureFlag.ts @@ -4,15 +4,20 @@ import { usePublicSettings } from '@/react/portainer/settings/queries'; export enum FeatureFlag { EdgeRemoteUpdate = 'edgeRemoteUpdate', + BEUpgrade = 'beUpgrade', } export function useFeatureFlag( flag: FeatureFlag, - { onSuccess }: { onSuccess?: (isEnabled: boolean) => void } = {} + { + onSuccess, + enabled = true, + }: { onSuccess?: (isEnabled: boolean) => void; enabled?: boolean } = {} ) { return usePublicSettings({ select: (settings) => settings.Features[flag], onSuccess, + enabled, }); } diff --git a/app/react/portainer/status/build-url.ts b/app/react/portainer/status/build-url.ts new file mode 100644 index 000000000..b0b9f5e57 --- /dev/null +++ b/app/react/portainer/status/build-url.ts @@ -0,0 +1,9 @@ +export function buildUrl(action?: string) { + let url = '/status'; + + if (action) { + url += `/${action}`; + } + + return url; +} diff --git a/app/react/portainer/status/useNodesCount.ts b/app/react/portainer/status/useNodesCount.ts new file mode 100644 index 000000000..bdeb9ebc2 --- /dev/null +++ b/app/react/portainer/status/useNodesCount.ts @@ -0,0 +1,25 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { buildUrl } from './build-url'; + +export interface NodesCountResponse { + nodes: number; +} + +async function getNodesCount() { + try { + const { data } = await axios.get(buildUrl('nodes')); + return data.nodes; + } catch (error) { + throw parseAxiosError(error as Error); + } +} + +export function useNodesCount() { + return useQuery(['status', 'nodes'], getNodesCount, { + ...withError('Unable to retrieve nodes count'), + }); +} diff --git a/app/react/portainer/status/useSystemInfo.ts b/app/react/portainer/status/useSystemInfo.ts new file mode 100644 index 000000000..d4bf4d360 --- /dev/null +++ b/app/react/portainer/status/useSystemInfo.ts @@ -0,0 +1,28 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; + +import { buildUrl } from './build-url'; + +export interface SystemInfoResponse { + platform: string; + agents: number; + edgeAgents: number; + edgeDevices: number; +} + +async function getSystemInfo() { + try { + const { data } = await axios.get(buildUrl('system')); + return data; + } catch (error) { + throw parseAxiosError(error as Error); + } +} + +export function useSystemInfo() { + return useQuery(['status', 'system'], getSystemInfo, { + ...withError('Unable to retrieve system info'), + }); +} diff --git a/app/react/sidebar/Sidebar.module.css b/app/react/sidebar/Sidebar.module.css index 5dac4ead1..293e48b58 100644 --- a/app/react/sidebar/Sidebar.module.css +++ b/app/react/sidebar/Sidebar.module.css @@ -32,6 +32,9 @@ z-index: 999; transition: all 0.4s ease 0s; +} + +.nav { background-color: var(--bg-sidebar-color); } diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx index 6d1eb1652..b8b0a819d 100644 --- a/app/react/sidebar/Sidebar.tsx +++ b/app/react/sidebar/Sidebar.tsx @@ -13,6 +13,7 @@ import { SidebarItem } from './SidebarItem'; import { Footer } from './Footer'; import { Header } from './Header'; import { SidebarProvider } from './useSidebarState'; +import { UpgradeBEBanner } from './UpgradeBEBanner'; export function Sidebar() { const { isAdmin, user } = useUser(); @@ -29,31 +30,35 @@ export function Sidebar() { return ( /* in the future (when we remove r2a) this should wrap the whole app - to change root styles */ - +
+ + +
); } diff --git a/app/react/sidebar/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner.tsx new file mode 100644 index 000000000..f016f0375 --- /dev/null +++ b/app/react/sidebar/UpgradeBEBanner.tsx @@ -0,0 +1,63 @@ +import { ArrowRight } from 'react-feather'; + +import { useAnalytics } from '@/angulartics.matomo/analytics-services'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { + useFeatureFlag, + FeatureFlag, +} from '@/react/portainer/feature-flags/useRedirectFeatureFlag'; +import { useNodesCount } from '@/react/portainer/status/useNodesCount'; +import { useSystemInfo } from '@/react/portainer/status/useSystemInfo'; + +import { useSidebarState } from './useSidebarState'; + +export function UpgradeBEBanner() { + const { data } = useFeatureFlag(FeatureFlag.BEUpgrade, { enabled: !isBE }); + + if (isBE || !data) { + return null; + } + + return ; +} + +function Inner() { + const { trackEvent } = useAnalytics(); + const { isOpen } = useSidebarState(); + const nodesCountQuery = useNodesCount(); + const systemInfoQuery = useSystemInfo(); + + if (!nodesCountQuery.data || !systemInfoQuery.data) { + return null; + } + + const nodesCount = nodesCountQuery.data; + const systemInfo = systemInfoQuery.data; + + const metadata = { + upgrade: false, + nodeCount: nodesCount, + platform: systemInfo.platform, + edgeAgents: systemInfo.edgeAgents, + edgeDevices: systemInfo.edgeDevices, + agents: systemInfo.agents, + }; + + return ( + + ); + + function handleClick() { + trackEvent('portainer-upgrade-admin', { + category: 'portainer', + metadata, + }); + } +}