feat(system/upgrade): add upgrade banner [EE-4564] (#8046)

pull/7974/head^2
Chaim Lev-Ari 2 years ago committed by GitHub
parent c21921a08d
commit eccc8131dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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
}

@ -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(),
})
}

@ -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})
}

@ -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(),
})
}

@ -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

@ -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
}

@ -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{}

@ -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
}

@ -1499,10 +1499,12 @@ const (
)
const FeatureFlagEdgeRemoteUpdate Feature = "edgeRemoteUpdate"
const FeatureFlagBEUpgrade = "beUpgrade"
// List of supported features
var SupportedFeatureFlags = []Feature{
FeatureFlagEdgeRemoteUpdate,
FeatureFlagBEUpgrade,
}
const (

@ -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<boolean>({
select: (settings) => settings.Features[flag],
onSuccess,
enabled,
});
}

@ -0,0 +1,9 @@
export function buildUrl(action?: string) {
let url = '/status';
if (action) {
url += `/${action}`;
}
return url;
}

@ -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<NodesCountResponse>(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'),
});
}

@ -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<SystemInfoResponse>(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'),
});
}

@ -32,6 +32,9 @@
z-index: 999;
transition: all 0.4s ease 0s;
}
.nav {
background-color: var(--bg-sidebar-color);
}

@ -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 */
<SidebarProvider>
<nav className={clsx(styles.root, 'p-5 flex flex-col')} aria-label="Main">
<Header logo={LogoURL} />
{/* negative margin + padding -> scrollbar won't hide the content */}
<div className="mt-6 overflow-y-auto flex-1 -mr-4 pr-4">
<ul className="space-y-9">
<SidebarItem
to="portainer.home"
icon={Home}
label="Home"
data-cy="portainerSidebar-home"
/>
<EnvironmentSidebar />
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
</ul>
</div>
<div className="mt-auto pt-8">
<Footer />
</div>
</nav>
<div className={clsx(styles.root, 'flex flex-col')}>
<UpgradeBEBanner />
<nav
className={clsx(
styles.nav,
'p-5 flex flex-col flex-1 overflow-y-auto'
)}
aria-label="Main"
>
<Header logo={LogoURL} />
{/* negative margin + padding -> scrollbar won't hide the content */}
<div className="mt-6 overflow-y-auto flex-1 -mr-4 pr-4">
<ul className="space-y-9">
<SidebarItem
to="portainer.home"
icon={Home}
label="Home"
data-cy="portainerSidebar-home"
/>
<EnvironmentSidebar />
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
<SettingsSidebar isAdmin={isAdmin} isTeamLeader={isTeamLeader} />
</ul>
</div>
<div className="mt-auto pt-8">
<Footer />
</div>
</nav>
</div>
</SidebarProvider>
);
}

@ -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 <Inner />;
}
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 (
<button
type="button"
className="border-0 bg-warning-5 text-warning-9 w-full min-h-[48px] h-12 font-semibold flex justify-center items-center gap-3"
onClick={handleClick}
>
{isOpen && <>Upgrade to Business Edition</>}
<ArrowRight className="text-lg feather" />
</button>
);
function handleClick() {
trackEvent('portainer-upgrade-admin', {
category: 'portainer',
metadata,
});
}
}
Loading…
Cancel
Save