feat(system): path to upgrade standalone to BE [EE-4071] (#8095)

pull/8165/head
Chaim Lev-Ari 2 years ago committed by GitHub
parent 756ac034ec
commit 5cbf52377d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -105,6 +105,11 @@ overrides:
'no-await-in-loop': 'off'
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
overrides: # allow props spreading for hoc files
- files:
- app/**/with*.ts{,x}
rules:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
extends:

@ -9,6 +9,8 @@ import (
"strconv"
"strings"
libstack "github.com/portainer/docker-compose-wrapper"
"github.com/portainer/docker-compose-wrapper/compose"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/build"
@ -35,6 +37,7 @@ import (
"github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/internal/upgrade"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
@ -147,8 +150,8 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
return store
}
func initComposeStackManager(assetsPath string, configPath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(assetsPath, configPath, proxyManager)
func initComposeStackManager(composeDeployer libstack.Deployer, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
log.Fatal().Err(err).Msg("failed creating compose manager")
}
@ -629,7 +632,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
dockerConfigPath := fileService.GetDockerConfigPath()
composeStackManager := initComposeStackManager(*flags.Assets, dockerConfigPath, reverseTunnelService, proxyManager)
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing compose deployer")
}
composeStackManager := initComposeStackManager(composeDeployer, reverseTunnelService, proxyManager)
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
@ -715,6 +723,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Msg("failed to fetch SSL settings from DB")
}
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing upgrade service")
}
// FIXME: In 2.16 we changed the way ingress controller permissions are
// stored. Instead of being stored as annotation on an ingress rule, we keep
// them in our database. However, in order to run the migration we need an
@ -767,6 +780,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
ShutdownTrigger: shutdownTrigger,
StackDeployer: stackDeployer,
DemoService: demoService,
UpgradeService: upgradeService,
}
}

@ -9,7 +9,6 @@ import (
"strings"
libstack "github.com/portainer/docker-compose-wrapper"
"github.com/portainer/docker-compose-wrapper/compose"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
@ -25,11 +24,7 @@ type ComposeStackManager struct {
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(binaryPath string, configPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
deployer, err := compose.NewComposeDeployer(binaryPath, configPath)
if err != nil {
return nil, err
}
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
return &ComposeStackManager{
deployer: deployer,
@ -43,7 +38,7 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
}
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRereate bool) error {
func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRecreate bool) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return errors.Wrap(err, "failed to fetch environment proxy")
@ -53,13 +48,21 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
defer proxy.Close()
}
envFile, err := createEnvFile(stack)
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, false)
err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile, forceRereate)
err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{
Options: libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
},
ForceRecreate: forceRecreate,
})
return errors.Wrap(err, "failed to deploy a stack")
}
@ -73,14 +76,19 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
defer proxy.Close()
}
envFile, err := createEnvFile(stack)
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, false)
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile)
err = manager.deployer.Remove(ctx, filePaths, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
})
return errors.Wrap(err, "failed to remove a stack")
}
@ -95,13 +103,18 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
defer proxy.Close()
}
envFile, err := createEnvFile(stack)
envFilePath, err := createEnvFile(stack)
if err != nil {
return errors.Wrap(err, "failed to create env file")
}
filePaths := stackutils.GetStackFilePaths(stack, false)
err = manager.deployer.Pull(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile)
err = manager.deployer.Pull(ctx, filePaths, libstack.Options{
WorkingDir: stack.ProjectPath,
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
})
return errors.Wrap(err, "failed to pull images of the stack")
}

@ -9,6 +9,7 @@ import (
"strings"
"testing"
"github.com/portainer/docker-compose-wrapper/compose"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/testhelpers"
@ -47,7 +48,12 @@ func Test_UpAndDown(t *testing.T) {
stack, endpoint := setup(t)
w, err := NewComposeStackManager("", "", nil)
deployer, err := compose.NewComposeDeployer("", "")
if err != nil {
t.Fatal(err)
}
w, err := NewComposeStackManager(deployer, nil)
if err != nil {
t.Fatalf("Failed creating manager: %s", err)
}

@ -489,14 +489,7 @@ func (service *Service) createDirectoryInStore(name string) error {
func (service *Service) createFileInStore(filePath string, r io.Reader) error {
path := service.wrapFileStore(filePath)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, r)
return err
return CreateFile(path, r)
}
// createBackupFileInStore makes a copy in the file store.
@ -762,3 +755,15 @@ func (service *Service) StoreFDOProfileFileFromBytes(fdoProfileIdentifier string
return service.wrapFileStore(filePath), nil
}
func CreateFile(path string, r io.Reader) error {
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, r)
return err
}

@ -9,6 +9,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.11.1
github.com/aws/aws-sdk-go-v2/credentials v1.6.2
github.com/aws/aws-sdk-go-v2/service/ecr v1.10.1
github.com/cbroglie/mustache v1.4.0
github.com/coreos/go-semver v0.3.0
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
github.com/docker/cli v20.10.9+incompatible
@ -34,14 +35,14 @@ require (
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021
github.com/portainer/docker-compose-wrapper v0.0.0-20221122145319-915b021aea84
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
github.com/portainer/libhttp v0.0.0-20220916153711-5d61e12f4b0a
github.com/portainer/libhttp v0.0.0-20221121135534-76f46e09c9a9
github.com/portainer/portainer/pkg/libhelm v0.0.0-20221201012749-4fee35924724
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73
github.com/robfig/cron/v3 v3.0.1
github.com/rs/zerolog v1.28.0
github.com/stretchr/testify v1.8.0
github.com/stretchr/testify v1.8.1
github.com/viney-shih/go-lock v1.1.1
go.etcd.io/bbolt v1.3.6
golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd

@ -75,6 +75,8 @@ github.com/aws/aws-sdk-go-v2/service/sso v1.6.1/go.mod h1:/73aFBwUl60wKBKhdth2pE
github.com/aws/aws-sdk-go-v2/service/sts v1.10.1/go.mod h1:+BmlPeQ1Y+PuIho93MMKDby12PoUnt1SZXQdEHCzSlw=
github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58=
github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU=
github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@ -155,8 +157,8 @@ github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
@ -334,16 +336,16 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021 h1:GFTn2e5AyIoBuK6hXbdVNkuV2m450DQnYmgQDZRU3x8=
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
github.com/portainer/docker-compose-wrapper v0.0.0-20221122145319-915b021aea84 h1:d1P8i0pCPvAfxH6nSLUFm6NYoi8tMrIpafaZXSV8Lac=
github.com/portainer/docker-compose-wrapper v0.0.0-20221122145319-915b021aea84/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk=
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
github.com/portainer/libhttp v0.0.0-20220916153711-5d61e12f4b0a h1:BJ5V4EDNhg3ImYbmXnGS8vrMhq6rzsEneIXyJh0g4dc=
github.com/portainer/libhttp v0.0.0-20220916153711-5d61e12f4b0a/go.mod h1:ckuHnoLA5kLuE5WkvPBXmrw63LUMdSH4aX71QRi9y10=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73/go.mod h1:E2w/A6qsKuG2VyiUubPdXpDyPykWfQqxuCs0YNS0MhM=
github.com/portainer/libhttp v0.0.0-20221121135534-76f46e09c9a9 h1:L7o0L+1qq+LzKjzgRB6bDIh5ZrZ5A1oSS+WgWzDgJIo=
github.com/portainer/libhttp v0.0.0-20221121135534-76f46e09c9a9/go.mod h1:H49JLiywwLt2rrJVroafEWy8fIs0i7mThAThK40sbb8=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20221201012749-4fee35924724 h1:FZrRVMpxXdUV+p5VSCAy9Uz7RzAeEJr2ytlctvMrsHY=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20221201012749-4fee35924724/go.mod h1:WUdwNVH9GMffP4qf4U2ea2qCYfti2V7S+IhGpO8Sxv0=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73/go.mod h1:E2w/A6qsKuG2VyiUubPdXpDyPykWfQqxuCs0YNS0MhM=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
@ -368,6 +370,7 @@ github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -375,8 +378,9 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV2eI=

@ -29,8 +29,8 @@ import (
"github.com/portainer/portainer/api/http/handler/settings"
"github.com/portainer/portainer/api/http/handler/ssl"
"github.com/portainer/portainer/api/http/handler/stacks"
"github.com/portainer/portainer/api/http/handler/status"
"github.com/portainer/portainer/api/http/handler/storybook"
"github.com/portainer/portainer/api/http/handler/system"
"github.com/portainer/portainer/api/http/handler/tags"
"github.com/portainer/portainer/api/http/handler/teammemberships"
"github.com/portainer/portainer/api/http/handler/teams"
@ -69,8 +69,8 @@ type Handler struct {
OpenAMTHandler *openamt.Handler
FDOHandler *fdo.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
StorybookHandler *storybook.Handler
SystemHandler *system.Handler
TagHandler *tags.Handler
TeamMembershipHandler *teammemberships.Handler
TeamHandler *teams.Handler
@ -133,8 +133,6 @@ type Handler struct {
// @tag.description Manage roles
// @tag.name settings
// @tag.description Manage Portainer settings
// @tag.name status
// @tag.description Information about the Portainer instance
// @tag.name users
// @tag.description Manage users
// @tag.name tags
@ -155,6 +153,10 @@ type Handler struct {
// @tag.description Manage webhooks
// @tag.name websocket
// @tag.description Create exec sessions using websockets
// @tag.name status
// @tag.description Information about the Portainer instance
// @tag.name system
// @tag.description Manage Portainer system
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@ -218,7 +220,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/stacks"):
http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/status"):
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
http.StripPrefix("/api", h.SystemHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/system"):
http.StripPrefix("/api", h.SystemHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/tags"):
http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/templates/helm"):

@ -1,41 +0,0 @@
package status
import (
"net/http"
"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"
)
// Handler is the HTTP handler used to handle status operations.
type Handler struct {
*mux.Router
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, dataStore dataservices.DataStore) *Handler {
h := &Handler{
Router: mux.NewRouter(),
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
}

@ -1,30 +0,0 @@
package status
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/demo"
)
type status struct {
*portainer.Status
DemoEnvironment demo.EnvironmentDetails
}
// @id StatusInspect
// @summary Check Portainer status
// @description Retrieve Portainer status
// @description **Access policy**: public
// @tags status
// @produce json
// @success 200 {object} status "Success"
// @router /status [get]
func (handler *Handler) statusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
return response.JSON(w, &status{
Status: handler.status,
DemoEnvironment: handler.demoService.Details(),
})
}

@ -0,0 +1,67 @@
package system
import (
"net/http"
"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"
"github.com/portainer/portainer/api/internal/upgrade"
)
// Handler is the HTTP handler used to handle status operations.
type Handler struct {
*mux.Router
status *portainer.Status
dataStore dataservices.DataStore
demoService *demo.Service
upgradeService upgrade.Service
}
// NewHandler creates a handler to manage status operations.
func NewHandler(bouncer *security.RequestBouncer,
status *portainer.Status,
demoService *demo.Service,
dataStore dataservices.DataStore,
upgradeService upgrade.Service) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dataStore: dataStore,
demoService: demoService,
status: status,
upgradeService: upgradeService,
}
router := h.PathPrefix("/system").Subrouter()
adminRouter := router.PathPrefix("/").Subrouter()
adminRouter.Use(bouncer.AdminAccess)
adminRouter.Handle("/upgrade", httperror.LoggerHandler(h.systemUpgrade)).Methods(http.MethodPost)
authenticatedRouter := router.PathPrefix("/").Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.Handle("/version", http.HandlerFunc(h.version)).Methods(http.MethodGet)
authenticatedRouter.Handle("/nodes", httperror.LoggerHandler(h.systemNodesCount)).Methods(http.MethodGet)
authenticatedRouter.Handle("/info", httperror.LoggerHandler(h.systemInfo)).Methods(http.MethodGet)
publicRouter := router.PathPrefix("/").Subrouter()
publicRouter.Use(bouncer.PublicAccess)
publicRouter.Handle("/status", httperror.LoggerHandler(h.systemStatus)).Methods(http.MethodGet)
// Deprecated /status endpoint, will be removed in the future.
h.Handle("/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspectDeprecated))).Methods(http.MethodGet)
h.Handle("/status/version",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.versionDeprecated))).Methods(http.MethodGet)
h.Handle("/status/nodes",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.statusNodesCountDeprecated))).Methods(http.MethodGet)
return h
}

@ -1,4 +1,4 @@
package status
package system
import (
"net/http"
@ -7,23 +7,24 @@ import (
"github.com/portainer/libhttp/response"
statusutil "github.com/portainer/portainer/api/internal/nodes"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/rs/zerolog/log"
)
type nodesCountResponse struct {
Nodes int `json:"nodes"`
}
// @id statusNodesCount
// @id systemNodesCount
// @summary Retrieve the count of nodes
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @tags system
// @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 {
// @router /system/nodes [get]
func (handler *Handler) systemNodesCount(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)
@ -40,3 +41,21 @@ func (handler *Handler) statusNodesCount(w http.ResponseWriter, r *http.Request)
return response.JSON(w, &nodesCountResponse{Nodes: nodes})
}
// @id statusNodesCount
// @summary Retrieve the count of nodes
// @deprecated
// @description Deprecated: use the `/system/nodes` endpoint instead.
// @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) statusNodesCountDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
log.Warn().Msg("The /status/nodes endpoint is deprecated, please use the /system/nodes endpoint instead")
return handler.systemNodesCount(w, r)
}

@ -0,0 +1,48 @@
package system
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/demo"
"github.com/rs/zerolog/log"
)
type status struct {
*portainer.Status
DemoEnvironment demo.EnvironmentDetails
}
// @id systemStatus
// @summary Check Portainer status
// @description Retrieve Portainer status
// @description **Access policy**: public
// @tags system
// @produce json
// @success 200 {object} status "Success"
// @router /system/status [get]
func (handler *Handler) systemStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
return response.JSON(w, &status{
Status: handler.status,
DemoEnvironment: handler.demoService.Details(),
})
}
// swagger docs for deprecated route:
// @id StatusInspect
// @summary Check Portainer status
// @deprecated
// @description Deprecated: use the `/system/status` endpoint instead.
// @description Retrieve Portainer status
// @description **Access policy**: public
// @tags status
// @produce json
// @success 200 {object} status "Success"
// @router /status [get]
func (handler *Handler) statusInspectDeprecated(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
log.Warn().Msg("The /status endpoint is deprecated and will be removed in a future version of Portainer. Please use the /system/status endpoint instead.")
return handler.systemStatus(w, r)
}

@ -1,4 +1,4 @@
package status
package system
import (
"net/http"
@ -7,6 +7,7 @@ import (
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/platform"
plf "github.com/portainer/portainer/api/platform"
)
type systemInfoResponse struct {
@ -16,17 +17,17 @@ type systemInfoResponse struct {
Agents int `json:"agents"`
}
// @id statusSystem
// @id systemInfo
// @summary Retrieve system info
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @tags system
// @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 {
// @router /system/info [get]
func (handler *Handler) systemInfo(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)
@ -48,13 +49,17 @@ func (handler *Handler) statusSystem(w http.ResponseWriter, r *http.Request) *ht
if environment.IsEdgeDevice {
edgeDevices++
}
}
platform, err := plf.DetermineContainerPlatform()
if err != nil {
return httperror.InternalServerError("Unable to determine container platform", err)
}
return response.JSON(w, &systemInfoResponse{
EdgeAgents: edgeAgents,
EdgeDevices: edgeDevices,
Agents: agents,
Platform: platform.DetermineContainerPlatform(),
Platform: platform,
})
}

@ -0,0 +1,54 @@
package system
import (
"net/http"
"regexp"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/rs/zerolog/log"
)
type systemUpgradePayload struct {
License string
}
var re = regexp.MustCompile(`^\d-.+`)
func (payload *systemUpgradePayload) Validate(r *http.Request) error {
if payload.License == "" {
return errors.New("license is missing")
}
if !re.MatchString(payload.License) {
return errors.New("license is invalid")
}
return nil
}
// @id systemUpgrade
// @summary Upgrade Portainer to BE
// @description Upgrade Portainer to BE
// @description **Access policy**: administrator
// @tags system
// @produce json
// @success 200 {object} status "Success"
// @router /system/upgrade [post]
func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
payload, err := request.GetPayload[systemUpgradePayload](r)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
go func() {
err = handler.upgradeService.Upgrade(payload.License)
if err != nil {
log.Error().Err(err).Msg("Failed to upgrade Portainer")
}
}()
return response.Empty(w)
}

@ -1,4 +1,4 @@
package status
package system
import (
"encoding/json"
@ -33,16 +33,16 @@ type BuildInfo struct {
GoVersion string
}
// @id Version
// @id systemVersion
// @summary Check for portainer updates
// @description Check if portainer has an update available
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @tags system
// @produce json
// @success 200 {object} versionResponse "Success"
// @router /status/version [get]
// @router /system/version [get]
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
result := &versionResponse{
@ -106,3 +106,21 @@ func HasNewerVersion(currentVersion, latestVersion string) bool {
return currentVersionSemver.LessThan(*latestVersionSemver)
}
// @id Version
// @summary Check for portainer updates
// @deprecated
// @description Deprecated: use the `/system/version` endpoint instead.
// @description Check if portainer has an update available
// @description **Access policy**: authenticated
// @security ApiKeyAuth
// @security jwt
// @tags status
// @produce json
// @success 200 {object} versionResponse "Success"
// @router /status/version [get]
func (handler *Handler) versionDeprecated(w http.ResponseWriter, r *http.Request) {
log.Warn().Msg("The /status/version endpoint is deprecated, please use the /system/version endpoint instead")
handler.version(w, r)
}

@ -40,8 +40,8 @@ import (
"github.com/portainer/portainer/api/http/handler/settings"
sslhandler "github.com/portainer/portainer/api/http/handler/ssl"
"github.com/portainer/portainer/api/http/handler/stacks"
"github.com/portainer/portainer/api/http/handler/status"
"github.com/portainer/portainer/api/http/handler/storybook"
"github.com/portainer/portainer/api/http/handler/system"
"github.com/portainer/portainer/api/http/handler/tags"
"github.com/portainer/portainer/api/http/handler/teammemberships"
"github.com/portainer/portainer/api/http/handler/teams"
@ -57,6 +57,7 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/internal/upgrade"
k8s "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/scheduler"
@ -103,6 +104,7 @@ type Server struct {
ShutdownTrigger context.CancelFunc
StackDeployer deployments.StackDeployer
DemoService *demo.Service
UpgradeService upgrade.Service
}
// Start starts the HTTP server
@ -251,7 +253,11 @@ func (server *Server) Start() error {
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.DataStore = server.DataStore
var statusHandler = status.NewHandler(requestBouncer, server.Status, server.DemoService, server.DataStore)
var systemHandler = system.NewHandler(requestBouncer,
server.Status,
server.DemoService,
server.DataStore,
server.UpgradeService)
var templatesHandler = templates.NewHandler(requestBouncer)
templatesHandler.DataStore = server.DataStore
@ -301,9 +307,9 @@ func (server *Server) Start() error {
ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler,
SSLHandler: sslHandler,
StatusHandler: statusHandler,
StackHandler: stackHandler,
StorybookHandler: storybookHandler,
SystemHandler: systemHandler,
TagHandler: tagHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,

@ -0,0 +1,125 @@
package upgrade
import (
"bytes"
"context"
"fmt"
"os"
"time"
"github.com/cbroglie/mustache"
"github.com/pkg/errors"
libstack "github.com/portainer/docker-compose-wrapper"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/platform"
"github.com/rs/zerolog/log"
)
const (
// mustacheUpgradeStandaloneTemplateFile represents the name of the template file for the standalone upgrade
mustacheUpgradeStandaloneTemplateFile = "upgrade-standalone.yml.mustache"
// portainerImagePrefixEnvVar represents the name of the environment variable used to define the image prefix for portainer-updater
// useful if there's a need to test PR images
portainerImagePrefixEnvVar = "UPGRADE_PORTAINER_IMAGE_PREFIX"
// skipPullImageEnvVar represents the name of the environment variable used to define if the image pull should be skipped
// useful if there's a need to test local images
skipPullImageEnvVar = "UPGRADE_SKIP_PULL_PORTAINER_IMAGE"
// updaterImageEnvVar represents the name of the environment variable used to define the updater image
// useful if there's a need to test a different updater
updaterImageEnvVar = "UPGRADE_UPDATER_IMAGE"
)
type Service interface {
Upgrade(licenseKey string) error
}
type service struct {
composeDeployer libstack.Deployer
isUpdating bool
platform platform.ContainerPlatform
assetsPath string
}
func NewService(assetsPath string, composeDeployer libstack.Deployer) (Service, error) {
platform, err := platform.DetermineContainerPlatform()
if err != nil {
return nil, errors.Wrap(err, "failed to determine container platform")
}
return &service{
assetsPath: assetsPath,
composeDeployer: composeDeployer,
platform: platform,
}, nil
}
func (service *service) Upgrade(licenseKey string) error {
service.isUpdating = true
switch service.platform {
case platform.PlatformDockerStandalone:
return service.UpgradeDockerStandalone(licenseKey, portainer.APIVersion)
// case platform.PlatformDockerSwarm:
// case platform.PlatformKubernetes:
// case platform.PlatformPodman:
// case platform.PlatformNomad:
// default:
}
return errors.New("unsupported platform")
}
func (service *service) UpgradeDockerStandalone(licenseKey, version string) error {
templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeStandaloneTemplateFile)
portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar)
if portainerImagePrefix == "" {
portainerImagePrefix = "portainer/portainer-ee"
}
image := fmt.Sprintf("%s:%s", portainerImagePrefix, version)
skipPullImage := os.Getenv(skipPullImageEnvVar)
composeFile, err := mustache.RenderFile(templateName, map[string]string{
"image": image,
"skip_pull_image": skipPullImage,
"updater_image": os.Getenv(updaterImageEnvVar),
"license": licenseKey,
})
log.Debug().
Str("composeFile", composeFile).
Msg("Compose file for upgrade")
if err != nil {
return errors.Wrap(err, "failed to render upgrade template")
}
tmpDir := os.TempDir()
filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", time.Now().Unix()))
r := bytes.NewReader([]byte(composeFile))
err = filesystem.CreateFile(filePath, r)
if err != nil {
return errors.Wrap(err, "failed to create upgrade compose file")
}
err = service.composeDeployer.Deploy(
context.Background(),
[]string{filePath},
libstack.DeployOptions{
ForceRecreate: true,
AbortOnContainerExit: true,
},
)
if err != nil {
return errors.Wrap(err, "failed to deploy upgrade stack")
}
return nil
}

@ -1,6 +1,12 @@
package platform
import "os"
import (
"context"
"os"
"github.com/docker/docker/client"
"github.com/pkg/errors"
)
const (
PodmanMode = "PODMAN"
@ -12,8 +18,10 @@ const (
type ContainerPlatform string
const (
// PlatformDocker represent the Docker platform (Standalone/Swarm)
PlatformDocker = ContainerPlatform("Docker")
// PlatformDockerStandalone represent the Docker platform (Standalone)
PlatformDockerStandalone = ContainerPlatform("Docker Standalone")
// PlatformDockerSwarm represent the Docker platform (Swarm)
PlatformDockerSwarm = ContainerPlatform("Docker Swarm")
// PlatformKubernetes represent the Kubernetes platform
PlatformKubernetes = ContainerPlatform("Kubernetes")
// PlatformPodman represent the Podman platform (Standalone)
@ -26,19 +34,45 @@ const (
// 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 {
func DetermineContainerPlatform() (ContainerPlatform, error) {
podmanModeEnvVar := os.Getenv(PodmanMode)
if podmanModeEnvVar == "1" {
return PlatformPodman
return PlatformPodman, nil
}
serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost)
if serviceHostKubernetesEnvVar != "" {
return PlatformKubernetes
return PlatformKubernetes, nil
}
nomadJobName := os.Getenv(NomadJobName)
if nomadJobName != "" {
return PlatformNomad
return PlatformNomad, nil
}
dockerCli, err := client.NewClientWithOpts()
if err != nil {
return "", errors.WithMessage(err, "failed to create docker client")
}
defer dockerCli.Close()
if !isRunningInContainer() {
return "", nil
}
return PlatformDocker
info, err := dockerCli.Info(context.Background())
if err != nil {
return "", errors.WithMessage(err, "failed to retrieve docker info")
}
if info.Swarm.NodeID == "" {
return PlatformDockerStandalone, nil
}
return PlatformDockerSwarm, nil
}
// isRunningInContainer returns true if the process is running inside a container
// this code is taken from https://github.com/moby/libnetwork/blob/master/drivers/bridge/setup_bridgenetfiltering.go
func isRunningInContainer() bool {
_, err := os.Stat("/.dockerenv")
return !os.IsNotExist(err)
}

@ -1501,12 +1501,8 @@ const (
WebSocketKeepAlive = 1 * time.Hour
)
const FeatureFlagBEUpgrade = "beUpgrade"
// List of supported features
var SupportedFeatureFlags = []Feature{
FeatureFlagBEUpgrade,
}
var SupportedFeatureFlags = []Feature{}
const (
_ AuthenticationMethod = iota

@ -15,7 +15,6 @@ export const API_ENDPOINT_REGISTRIES = 'api/registries';
export const API_ENDPOINT_RESOURCE_CONTROLS = 'api/resource_controls';
export const API_ENDPOINT_SETTINGS = 'api/settings';
export const API_ENDPOINT_STACKS = 'api/stacks';
export const API_ENDPOINT_STATUS = 'api/status';
export const API_ENDPOINT_SUPPORT = 'api/support';
export const API_ENDPOINT_USERS = 'api/users';
export const API_ENDPOINT_TAGS = 'api/tags';
@ -53,7 +52,6 @@ angular
.constant('API_ENDPOINT_RESOURCE_CONTROLS', API_ENDPOINT_RESOURCE_CONTROLS)
.constant('API_ENDPOINT_SETTINGS', API_ENDPOINT_SETTINGS)
.constant('API_ENDPOINT_STACKS', API_ENDPOINT_STACKS)
.constant('API_ENDPOINT_STATUS', API_ENDPOINT_STATUS)
.constant('API_ENDPOINT_SUPPORT', API_ENDPOINT_SUPPORT)
.constant('API_ENDPOINT_USERS', API_ENDPOINT_USERS)
.constant('API_ENDPOINT_TAGS', API_ENDPOINT_TAGS)

@ -1,8 +1,7 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { getNodesCount } from '../services/api/status.service';
import { useNodesCount } from '@/react/portainer/system/useNodesCount';
import { getLicenseInfo } from './license.service';
import { LicenseInfo, LicenseType } from './types';
@ -21,22 +20,8 @@ export function useLicenseInfo() {
return { isLoading, info };
}
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}
export function useIntegratedLicenseInfo() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingNodes, data: nodesCount = 0 } = useNodesCount();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (

@ -1,15 +0,0 @@
angular.module('portainer.app').factory('Status', [
'$resource',
'API_ENDPOINT_STATUS',
function StatusFactory($resource, API_ENDPOINT_STATUS) {
'use strict';
return $resource(
API_ENDPOINT_STATUS + '/:action',
{},
{
get: { method: 'GET' },
version: { method: 'GET', params: { action: 'version' } },
}
);
},
]);

@ -1,80 +0,0 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '../axios';
export interface NodesCountResponse {
nodes: number;
}
export async function getNodesCount() {
try {
const { data } = await axios.get<NodesCountResponse>(buildUrl('nodes'));
return data.nodes;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export interface StatusResponse {
Edition: string;
Version: string;
InstanceID: string;
}
export async function getStatus() {
try {
const { data } = await axios.get<StatusResponse>(buildUrl());
data.Edition = 'Community Edition';
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useStatus<T = StatusResponse>(
select?: (status: StatusResponse) => T
) {
return useQuery(['status'], () => getStatus(), { select });
}
export interface VersionResponse {
// Whether portainer has an update available
UpdateAvailable: boolean;
// The latest version available
LatestVersion: string;
ServerVersion: string;
DatabaseVersion: string;
Build: {
BuildNumber: string;
ImageTag: string;
NodejsVersion: string;
YarnVersion: string;
WebpackVersion: string;
GoVersion: string;
};
}
export async function getVersionStatus() {
try {
const { data } = await axios.get<VersionResponse>(buildUrl('version'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useVersionStatus() {
return useQuery(['version'], () => getVersionStatus());
}
function buildUrl(action?: string) {
let url = '/status';
if (action) {
url += `/${action}`;
}
return url;
}

@ -1,42 +1,27 @@
import { StatusVersionViewModel, StatusViewModel } from '../../models/status';
import { getSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { StatusViewModel } from '../../models/status';
angular.module('portainer.app').factory('StatusService', [
'$q',
'Status',
function StatusServiceFactory($q, Status) {
'use strict';
var service = {};
angular.module('portainer.app').factory('StatusService', StatusServiceFactory);
service.status = function () {
var deferred = $q.defer();
/* @ngInject */
function StatusServiceFactory($q) {
'use strict';
var service = {};
Status.get()
.$promise.then(function success(data) {
var status = new StatusViewModel(data);
deferred.resolve(status);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve application status', err: err });
});
service.status = function () {
var deferred = $q.defer();
return deferred.promise;
};
getSystemStatus()
.then(function success(data) {
var status = new StatusViewModel(data);
deferred.resolve(status);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve application status', err: err });
});
service.version = function () {
var deferred = $q.defer();
return deferred.promise;
};
Status.version()
.$promise.then(function success(data) {
var status = new StatusVersionViewModel(data);
deferred.resolve(status);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve application version info', err: err });
});
return deferred.promise;
};
return service;
},
]);
return service;
}

@ -12,15 +12,31 @@ export default {
interface TextFieldProps {
label: string;
tooltip?: string;
vertical?: boolean;
required?: boolean;
error?: string;
}
export { TextField, SelectField };
function TextField({ label, tooltip = '' }: TextFieldProps) {
function TextField({
label,
tooltip = '',
required,
error,
vertical,
}: TextFieldProps) {
const [value, setValue] = useState('');
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<FormControl
inputId={inputId}
label={label}
tooltip={tooltip}
required={required}
errors={error}
size={vertical ? 'vertical' : undefined}
>
<Input
id={inputId}
type="text"
@ -34,9 +50,18 @@ function TextField({ label, tooltip = '' }: TextFieldProps) {
TextField.args = {
label: 'label',
tooltip: '',
vertical: false,
required: false,
error: '',
};
function SelectField({ label, tooltip = '' }: TextFieldProps) {
function SelectField({
label,
tooltip = '',
vertical,
required,
error,
}: TextFieldProps) {
const options = [
{ value: 1, label: 'one' },
{ value: 2, label: 'two' },
@ -44,7 +69,14 @@ function SelectField({ label, tooltip = '' }: TextFieldProps) {
const [value, setValue] = useState(0);
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<FormControl
inputId={inputId}
label={label}
tooltip={tooltip}
size={vertical ? 'vertical' : undefined}
required={required}
errors={error}
>
<Select
className="form-control"
value={value}
@ -58,4 +90,7 @@ function SelectField({ label, tooltip = '' }: TextFieldProps) {
SelectField.args = {
label: 'select',
tooltip: '',
vertical: false,
required: false,
error: '',
};

@ -5,9 +5,7 @@ import { Tooltip } from '@@/Tip/Tooltip';
import { FormError } from '../FormError';
import styles from './FormControl.module.css';
export type Size = 'xsmall' | 'small' | 'medium' | 'large';
export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
export interface Props {
inputId?: string;
@ -29,7 +27,12 @@ export function FormControl({
required,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('form-group', styles.container)}>
<div
className={clsx(
'form-group',
'after:content-[""] after:clear-both after:table' // to fix issues with float
)}
>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
@ -62,6 +65,8 @@ function sizeClassLabel(size?: Size) {
return 'col-sm-4 col-lg-3';
case 'xsmall':
return 'col-sm-2';
case 'vertical':
return '';
default:
return 'col-sm-3 col-lg-2';
}
@ -75,6 +80,8 @@ function sizeClassChildren(size?: Size) {
return 'col-sm-8 col-lg-9';
case 'xsmall':
return 'col-sm-8';
case 'vertical':
return '';
default:
return 'col-sm-9 col-lg-10';
}

@ -0,0 +1,22 @@
.close {
color: var(--button-close-color);
opacity: var(--button-opacity);
padding: 0;
cursor: pointer;
background: transparent;
border: 0;
appearance: none;
font-size: 21px;
font-weight: bold;
line-height: 1;
text-shadow: 0 1px 0 #fff;
filter: alpha(opacity=20);
}
.close:hover,
.close:focus {
color: var(--button-close-color);
opacity: var(--button-opacity-hover);
}

@ -0,0 +1,21 @@
import clsx from 'clsx';
import styles from './CloseButton.module.css';
export function CloseButton({
onClose,
className,
}: {
onClose: () => void;
className?: string;
}) {
return (
<button
type="button"
className={clsx(styles.close, className, 'absolute top-2 right-2')}
onClick={() => onClose()}
>
×
</button>
);
}

@ -0,0 +1,20 @@
.modal-dialog {
width: 450px;
display: inline-block;
text-align: left;
vertical-align: middle;
}
.modal-content {
background-color: var(--bg-modal-content-color);
padding: 20px;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
outline: 0;
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
}

@ -0,0 +1,53 @@
import { DialogContent, DialogOverlay } from '@reach/dialog';
import clsx from 'clsx';
import { createContext, PropsWithChildren, useContext } from 'react';
import { CloseButton } from './CloseButton';
import styles from './Modal.module.css';
const Context = createContext<boolean | null>(null);
Context.displayName = 'ModalContext';
export function useModalContext() {
const context = useContext(Context);
if (!context) {
throw new Error('should be nested under Modal');
}
return context;
}
interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
}
export function Modal({
children,
onDismiss,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: PropsWithChildren<Props>) {
return (
<Context.Provider value>
<DialogOverlay
isOpen
className="flex items-center justify-center z-50"
onDismiss={onDismiss}
role="dialog"
>
<DialogContent
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
>
<div className={clsx(styles.modalContent, 'relative')}>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>
</DialogContent>
</DialogOverlay>
</Context.Provider>
);
}

@ -0,0 +1,4 @@
.modal-body {
padding: 10px 0px;
border-bottom: none;
}

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useModalContext } from './Modal';
import styles from './ModalBody.module.css';
export function ModalBody({ children }: PropsWithChildren<unknown>) {
useModalContext();
return <div className={styles.modalBody}>{children}</div>;
}

@ -0,0 +1,5 @@
.modal-footer {
padding: 10px 0px;
border-top: none;
display: flex;
}

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { useModalContext } from './Modal';
import styles from './ModalFooter.module.css';
export function ModalFooter({ children }: PropsWithChildren<unknown>) {
useModalContext();
return (
<div className={clsx(styles.modalFooter, 'flex justify-end')}>
{children}
</div>
);
}

@ -0,0 +1,19 @@
.modal-header {
margin-bottom: 10px;
padding: 0px;
border-bottom: none;
}
.background-error {
padding-top: 55px;
background-image: url(~assets/images/icon-error.svg);
background-repeat: no-repeat;
background-position: top left;
}
.background-warning {
padding-top: 55px;
background-image: url(~assets/images/icon-warning.svg);
background-repeat: no-repeat;
background-position: top left;
}

@ -0,0 +1,33 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
import { ModalType } from './types';
import { useModalContext } from './Modal';
import styles from './ModalHeader.module.css';
interface Props {
title: ReactNode;
modalType?: ModalType;
}
export function ModalHeader({ title, modalType }: Props) {
useModalContext();
return (
<div className={styles.modalHeader}>
{modalType && (
<div
className={clsx({
[styles.backgroundError]: modalType === ModalType.Destructive,
[styles.backgroundWarning]: modalType === ModalType.Warn,
})}
/>
)}
{typeof title === 'string' ? (
<h5 className="font-bold">{title}</h5>
) : (
title
)}
</div>
);
}

@ -0,0 +1,18 @@
import { Modal as MainComponent } from './Modal';
import { ModalHeader } from './ModalHeader';
import { ModalBody } from './ModalBody';
import { ModalFooter } from './ModalFooter';
interface WithSubComponents {
Header: typeof ModalHeader;
Body: typeof ModalBody;
Footer: typeof ModalFooter;
}
const Modal = MainComponent as typeof MainComponent & WithSubComponents;
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export { Modal };

@ -0,0 +1,6 @@
export type OnSubmit<TResult> = (result?: TResult) => void;
export enum ModalType {
Warn = 'warning',
Destructive = 'error',
}

@ -0,0 +1,24 @@
import { ComponentType } from 'react';
/**
* Hides the wrapped component if portainer is running as a docker extension.
*/
export function withHideOnExtension<T>(
WrappedComponent: ComponentType<T>
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
if (window.ddExtension) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `withHideOnExtension(${displayName})`;
return WrapperComponent;
}

@ -11,7 +11,7 @@ test('when user is using more nodes then allowed he should see message', async (
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
rest.get('/api/system/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);
@ -32,7 +32,7 @@ test("when user is using less nodes then allowed he shouldn't see message", asyn
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
rest.get('/api/system/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);

@ -1,13 +1,11 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { LicenseType } from '@/portainer/license-management/types';
import { useLicenseInfo } from '@/portainer/license-management/use-license.service';
import { getNodesCount } from '@/portainer/services/api/status.service';
import { TextTip } from '@@/Tip/TextTip';
import { InformationPanel } from '@@/InformationPanel';
import { useNodesCount } from '../system/useNodesCount';
export function LicenseNodePanel() {
const nodesValid = useNodesValid();
@ -26,7 +24,7 @@ export function LicenseNodePanel() {
}
function useNodesValid() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingNodes, data: nodesCount = 0 } = useNodesCount();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
@ -40,17 +38,3 @@ function useNodesValid() {
return nodesCount <= info.nodes;
}
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}

@ -1,10 +1,10 @@
import { useStatus } from '@/portainer/services/api/status.service';
import { useSettings } from '@/react/portainer/settings/queries';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
export function useAgentDetails() {
const settingsQuery = useSettings();
const versionQuery = useStatus((status) => status.Version);
const versionQuery = useSystemStatus({ select: (status) => status.Version });
if (!versionQuery.isSuccess || !settingsQuery.isSuccess) {
return null;

@ -2,9 +2,7 @@ import { useRouter } from '@uirouter/react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export enum FeatureFlag {
BEUpgrade = 'beUpgrade',
}
export enum FeatureFlag {}
export function useFeatureFlag(
flag: FeatureFlag,

@ -0,0 +1,22 @@
import { ComponentType } from 'react';
export function withEdition<T>(
WrappedComponent: ComponentType<T>,
edition: 'BE' | 'CE'
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
if (process.env.PORTAINER_EDITION !== edition) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `with${edition}Edition(${displayName})`;
return WrapperComponent;
}

@ -0,0 +1,26 @@
import { ComponentType } from 'react';
import { FeatureFlag, useFeatureFlag } from './useRedirectFeatureFlag';
export function withFeatureFlag<T>(
WrappedComponent: ComponentType<T>,
flag: FeatureFlag
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
const featureFlagQuery = useFeatureFlag(flag);
if (!featureFlagQuery.data) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `with${flag}FeatureFlag(${displayName})`;
return WrapperComponent;
}

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

@ -0,0 +1,3 @@
export const queryKeys = {
base: () => ['system'] as const,
};

@ -4,6 +4,9 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'nodes'] as const;
export interface NodesCountResponse {
nodes: number;
@ -19,7 +22,7 @@ async function getNodesCount() {
}
export function useNodesCount() {
return useQuery(['status', 'nodes'], getNodesCount, {
return useQuery(queryKey, getNodesCount, {
...withError('Unable to retrieve nodes count'),
});
}

@ -4,9 +4,19 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'info'] as const;
export type ContainerPlatform =
| 'Docker Standalone'
| 'Docker Swarm'
| 'Kubernetes'
| 'Podman'
| 'Nomad';
export interface SystemInfoResponse {
platform: string;
platform: ContainerPlatform;
agents: number;
edgeAgents: number;
edgeDevices: number;
@ -14,7 +24,7 @@ export interface SystemInfoResponse {
async function getSystemInfo() {
try {
const { data } = await axios.get<SystemInfoResponse>(buildUrl('system'));
const { data } = await axios.get<SystemInfoResponse>(buildUrl('info'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
@ -22,7 +32,7 @@ async function getSystemInfo() {
}
export function useSystemInfo() {
return useQuery(['status', 'system'], getSystemInfo, {
return useQuery(queryKey, getSystemInfo, {
...withError('Unable to retrieve system info'),
});
}

@ -0,0 +1,46 @@
import { useQuery } from 'react-query';
import { RetryValue } from 'react-query/types/core/retryer';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'status'] as const;
export interface StatusResponse {
Edition: string;
Version: string;
InstanceID: string;
}
export async function getSystemStatus() {
try {
const { data } = await axios.get<StatusResponse>(buildUrl('status'));
data.Edition = 'Community Edition';
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useSystemStatus<T = StatusResponse>({
select,
enabled,
retry,
onSuccess,
}: {
select?: (status: StatusResponse) => T;
enabled?: boolean;
retry?: RetryValue<unknown>;
onSuccess?: (data: T) => void;
} = {}) {
return useQuery(queryKey, () => getSystemStatus(), {
select,
enabled,
retry,
onSuccess,
});
}

@ -0,0 +1,38 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'version'] as const;
export interface VersionResponse {
// Whether portainer has an update available
UpdateAvailable: boolean;
// The latest version available
LatestVersion: string;
ServerVersion: string;
DatabaseVersion: string;
Build: {
BuildNumber: string;
ImageTag: string;
NodejsVersion: string;
YarnVersion: string;
WebpackVersion: string;
GoVersion: string;
};
}
export async function getSystemVersion() {
try {
const { data } = await axios.get<VersionResponse>(buildUrl('version'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useSystemVersion() {
return useQuery(queryKey, () => getSystemVersion());
}

@ -0,0 +1,20 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
export function useUpgradeEditionMutation() {
return useMutation(upgradeEdition, {
...withError('Unable to upgrade edition'),
});
}
async function upgradeEdition({ license }: { license: string }) {
try {
await axios.post(buildUrl('upgrade'), { license });
} catch (error) {
throw parseAxiosError(error as Error);
}
}

@ -2,10 +2,8 @@ import { useState } from 'react';
import { Database, Hash, Server, Tag, Wrench } from 'lucide-react';
import { DialogOverlay } from '@reach/dialog';
import {
useStatus,
useVersionStatus,
} from '@/portainer/services/api/status.service';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import { Button } from '@@/buttons';
@ -13,7 +11,7 @@ import styles from './Footer.module.css';
export function BuildInfoModalButton() {
const [isBuildInfoVisible, setIsBuildInfoVisible] = useState(false);
const statusQuery = useStatus();
const statusQuery = useSystemStatus();
if (!statusQuery.data) {
return null;
@ -39,8 +37,8 @@ export function BuildInfoModalButton() {
}
function BuildInfoModal({ closeModal }: { closeModal: () => void }) {
const versionQuery = useVersionStatus();
const statusQuery = useStatus();
const versionQuery = useSystemVersion();
const statusQuery = useSystemStatus();
if (!statusQuery.data || !versionQuery.data) {
return null;

@ -23,15 +23,6 @@ function CEFooter() {
<span>Community Edition</span>
<BuildInfoModalButton />
<a
href="https://www.portainer.io/install-BE-now"
className="text-blue-6 font-medium"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</FooterContent>
</div>
);

@ -1,9 +1,8 @@
import { useQuery } from 'react-query';
import clsx from 'clsx';
import { DownloadCloud } from 'lucide-react';
import { getVersionStatus } from '@/portainer/services/api/status.service';
import { useUIState } from '@/react/hooks/useUIState';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import { Icon } from '@@/Icon';
@ -11,7 +10,7 @@ import styles from './UpdateNotifications.module.css';
export function UpdateNotification() {
const uiStateStore = useUIState();
const query = useUpdateNotification();
const query = useSystemVersion();
if (!query.data || !query.data.UpdateAvailable) {
return null;
@ -67,7 +66,3 @@ export function UpdateNotification() {
uiStateStore.dismissUpdateVersion(version);
}
}
function useUpdateNotification() {
return useQuery(['status', 'version'], () => getVersionStatus());
}

@ -13,7 +13,7 @@ import { SidebarItem } from './SidebarItem';
import { Footer } from './Footer';
import { Header } from './Header';
import { SidebarProvider } from './useSidebarState';
import { UpgradeBEBanner } from './UpgradeBEBanner';
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
export function Sidebar() {
const { isAdmin, user } = useUser();
@ -31,7 +31,7 @@ export function Sidebar() {
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */
<SidebarProvider>
<div className={clsx(styles.root, 'sidebar flex flex-col')}>
<UpgradeBEBanner />
<UpgradeBEBannerWrapper />
<nav
className={clsx(
styles.nav,

@ -1,63 +0,0 @@
import { ArrowRight } from 'lucide-react';
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 lucide" />
</button>
);
function handleClick() {
trackEvent('portainer-upgrade-admin', {
category: 'portainer',
metadata,
});
}
}

@ -0,0 +1,52 @@
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { Modal } from '@@/modals/Modal';
import { Icon } from '@@/Icon';
export function LoadingDialog() {
useWaitForServerStatus();
return (
<Modal aria-label="Upgrade Portainer to Business Edition">
<Modal.Body>
<div className="flex flex-col items-center justify-center w-full">
<Icon
icon={Loader2}
className="animate-spin-slow !text-8xl !text-blue-8"
aria-label="loading"
/>
<h1 className="!text-2xl">Upgrading Portainer...</h1>
<p className="text-center text-gray-6 text-xl">
Please wait while we upgrade your Portainer to Business Edition.
</p>
</div>
</Modal.Body>
</Modal>
);
}
function useWaitForServerStatus() {
const [enabled, setEnabled] = useState(false);
useSystemStatus({
enabled,
retry: true,
onSuccess() {
window.location.reload();
},
});
useEffect(() => {
const timeoutId = setTimeout(() => {
setEnabled(true);
}, 3000);
return () => {
clearTimeout(timeoutId);
};
});
}

@ -0,0 +1,52 @@
import { ExternalLink } from 'lucide-react';
import { Button } from '@@/buttons';
import { Modal } from '@@/modals/Modal';
import { ModalType } from '@@/modals/Modal/types';
export function NonAdminUpgradeDialog({
onDismiss,
}: {
onDismiss: () => void;
}) {
return (
<Modal aria-label="Upgrade Portainer to Business Edition">
<Modal.Header
title="Contact your administrator"
modalType={ModalType.Warn}
/>
<Modal.Body>
You need to be logged in as an admin to upgrade Portainer to Business
Edition.
</Modal.Body>
<Modal.Footer>
<div className="flex gap-2 w-full">
<Button
color="default"
size="medium"
className="w-1/3"
onClick={() => onDismiss()}
>
Cancel
</Button>
<a
href="https://www.portainer.io/take-5"
target="_blank"
rel="noreferrer"
className="no-link w-2/3"
>
<Button
color="primary"
size="medium"
className="w-full"
icon={ExternalLink}
>
Learn about Business Edition
</Button>
</a>
</div>
</Modal.Footer>
</Modal>
);
}

@ -0,0 +1,78 @@
import { ArrowRight } from 'lucide-react';
import { useState } from 'react';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useNodesCount } from '@/react/portainer/system/useNodesCount';
import {
ContainerPlatform,
useSystemInfo,
} from '@/react/portainer/system/useSystemInfo';
import { useUser } from '@/react/hooks/useUser';
import { withEdition } from '@/react/portainer/feature-flags/withEdition';
import { withHideOnExtension } from '@/react/hooks/withHideOnExtension';
import { useSidebarState } from '../useSidebarState';
import { UpgradeDialog } from './UpgradeDialog';
export const UpgradeBEBannerWrapper = withHideOnExtension(
withEdition(UpgradeBEBanner, 'CE')
);
const enabledPlatforms: Array<ContainerPlatform> = ['Docker Standalone'];
function UpgradeBEBanner() {
const { isAdmin } = useUser();
const { trackEvent } = useAnalytics();
const { isOpen: isSidebarOpen } = useSidebarState();
const nodesCountQuery = useNodesCount();
const systemInfoQuery = useSystemInfo();
const [isOpen, setIsOpen] = useState(false);
if (!nodesCountQuery.isSuccess || !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,
};
if (!enabledPlatforms.includes(systemInfo.platform)) {
return null;
}
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}
>
{isSidebarOpen && <>Upgrade to Business Edition</>}
<ArrowRight className="text-lg lucide" />
</button>
{isOpen && <UpgradeDialog onDismiss={() => setIsOpen(false)} />}
</>
);
function handleClick() {
trackEvent(
isAdmin ? 'portainer-upgrade-admin' : 'portainer-upgrade-non-admin',
{
category: 'portainer',
metadata,
}
);
setIsOpen(true);
}
}

@ -0,0 +1,41 @@
import { useState } from 'react';
import { useUser } from '@/react/hooks/useUser';
import { UploadLicenseDialog } from './UploadLicenseDialog';
import { LoadingDialog } from './LoadingDialog';
import { NonAdminUpgradeDialog } from './NonAdminUpgradeDialog';
type Step = 'uploadLicense' | 'loading' | 'getLicense';
export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
const { isAdmin } = useUser();
const [currentStep, setCurrentStep] = useState<Step>('uploadLicense');
const component = getDialog();
return component;
function getDialog() {
if (!isAdmin) {
return <NonAdminUpgradeDialog onDismiss={onDismiss} />;
}
switch (currentStep) {
case 'getLicense':
throw new Error('Not implemented');
// return <GetLicense setCurrentStep={setCurrentStep} />;
case 'uploadLicense':
return (
<UploadLicenseDialog
goToLoading={() => setCurrentStep('loading')}
onDismiss={onDismiss}
/>
);
case 'loading':
return <LoadingDialog />;
default:
throw new Error('step type not found');
}
}
}

@ -0,0 +1,107 @@
import { Field, Form, Formik } from 'formik';
import { object, SchemaOf, string } from 'yup';
import { ExternalLink } from 'lucide-react';
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Button, LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { Modal } from '@@/modals/Modal';
interface FormValues {
license: string;
}
const initialValues: FormValues = {
license: '',
};
export function UploadLicenseDialog({
onDismiss,
goToLoading,
}: {
onDismiss: () => void;
goToLoading: () => void;
}) {
const upgradeMutation = useUpgradeEditionMutation();
return (
<Modal
onDismiss={onDismiss}
aria-label="Upgrade Portainer to Business Edition"
>
<Modal.Header
title={<h4 className="font-medium text-xl">Upgrade Portainer</h4>}
/>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
>
{({ errors }) => (
<Form noValidate>
<Modal.Body>
<p className="font-semibold text-gray-7">
Please enter your Portainer License Below
</p>
<FormControl
label="License"
errors={errors.license}
required
size="vertical"
>
<Field name="license" as={Input} required />
</FormControl>
</Modal.Body>
<Modal.Footer>
<div className="flex gap-2 [&>*]:w-1/2 w-full">
<a
href="https://www.portainer.io/take-5"
target="_blank"
rel="noreferrer"
className="no-link"
>
<Button
color="default"
size="medium"
className="w-full"
icon={ExternalLink}
>
Get a license
</Button>
</a>
<LoadingButton
color="primary"
size="medium"
loadingText="Validating License"
isLoading={upgradeMutation.isLoading}
>
Start upgrade
</LoadingButton>
</div>
</Modal.Footer>
</Form>
)}
</Formik>
</Modal>
);
function handleSubmit(values: FormValues) {
upgradeMutation.mutate(values, {
onSuccess() {
notifySuccess('Starting upgrade', 'License validated successfully');
goToLoading();
},
});
}
}
function validation(): SchemaOf<FormValues> {
return object().shape({
license: string()
.required('License is required')
.matches(/^\d-.+/, 'License is invalid'),
});
}

@ -0,0 +1 @@
export { UpgradeBEBannerWrapper } from './UpgradeBEBanner';

@ -7,7 +7,7 @@ import {
} from '@/portainer/license-management/types';
import { EnvironmentGroup } from '@/react/portainer/environments/environment-groups/types';
import { Tag } from '@/portainer/tags/types';
import { StatusResponse } from '@/portainer/services/api/status.service';
import { StatusResponse } from '@/react/portainer/system/useSystemStatus';
import { createMockTeams } from '@/react-tools/test-mocks';
import { PublicSettingsResponse } from '@/react/portainer/settings/types';
import { UserId } from '@/portainer/users/types';

@ -11,6 +11,10 @@ $project_path = $((Get-Location).Path)
New-Item -Name dist -Path "$project_path" -ItemType Directory | Out-Null
Set-Location -Path "$project_path\api\cmd\portainer"
# copy templates
Copy-Item -Path "./mustache-templates" -Destination "./dist" -Recurse
C:\go\bin\go.exe get -t -d -v ./...
C:\go\bin\go.exe build -v

@ -12,6 +12,10 @@ YARN_VERSION="0"
WEBPACK_VERSION="0"
GO_VERSION="0"
# copy templates
cp -r "./mustache-templates" "./dist"
cd api
# the go get adds 8 seconds
go get -t -d -v ./...

@ -13,6 +13,8 @@ mkdir -p ${GOPATH}/src/github.com/portainer/portainer
cp -R api ${GOPATH}/src/github.com/portainer/portainer/api
cp -r "./mustache-templates" "./dist"
cd 'api/cmd/portainer'
go get -t -d -v ./...
@ -29,3 +31,4 @@ if [ "${PLATFORM}" == 'windows' ]; then
else
mv "$BUILD_SOURCESDIRECTORY/api/cmd/portainer/$binary" "$BUILD_SOURCESDIRECTORY/dist/portainer"
fi

@ -20,4 +20,6 @@ EXPOSE 9000
EXPOSE 9443
EXPOSE 8000
LABEL io.portainer.server true
ENTRYPOINT ["/portainer"]

@ -9,4 +9,6 @@ EXPOSE 9000
EXPOSE 9443
EXPOSE 8000
LABEL io.portainer.server true
ENTRYPOINT ["/portainer"]

@ -22,4 +22,6 @@ EXPOSE 9000
EXPOSE 9443
EXPOSE 8000
LABEL io.portainer.server true
ENTRYPOINT ["/portainer.exe"]

@ -0,0 +1,17 @@
version: "3"
services:
updater:
image: {{updater_image}}{{^updater_image}}portainer/portainer-updater:latest{{/updater_image}}
command: ["portainer",
"--image", "{{image}}{{^image}}portainer/portainer-ee:latest{{/image}}",
"--env-type", "standalone",
"--license", "{{license}}"
]
{{#skip_pull_image}}
environment:
- SKIP_PULL=1
{{/skip_pull_image}}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
{{! - \\.\pipe\docker_engine:\\.\pipe\docker_engine }}
Loading…
Cancel
Save