mirror of https://github.com/portainer/portainer
feat(system): path to upgrade standalone to BE [EE-4071] (#8095)
parent
756ac034ec
commit
5cbf52377d
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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,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;
|
||||
}
|
||||
|
@ -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,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;
|
||||
}
|
@ -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,
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
@ -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';
|
@ -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…
Reference in new issue