Compare commits
39 Commits
2.27.8
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c1977e0aa | ||
|
|
c47fd9f9ed | ||
|
|
767d1d1970 | ||
|
|
ef81e5c0e0 | ||
|
|
234b7a3d5e | ||
|
|
af49305e64 | ||
|
|
d181d1251c | ||
|
|
5f7db66e95 | ||
|
|
17378bdef6 | ||
|
|
010542ac1e | ||
|
|
1bb253479a | ||
|
|
f0a13a2ad1 | ||
|
|
f9b28aa0a1 | ||
|
|
d26e1b6983 | ||
|
|
7b00fdd208 | ||
|
|
14b998d270 | ||
|
|
605ff8c1da | ||
|
|
13f93f4262 | ||
|
|
16be5ed329 | ||
|
|
c6612898f3 | ||
|
|
564f34b0ba | ||
|
|
392fbdb4a7 | ||
|
|
a826c78786 | ||
|
|
a35f0607f1 | ||
|
|
081d32af0d | ||
|
|
4cc0b1f567 | ||
|
|
d4da7e1760 | ||
|
|
aced418880 | ||
|
|
614f42fe5a | ||
|
|
58736fe93b | ||
|
|
b78330b10d | ||
|
|
eed4a92ca8 | ||
|
|
0e7468a1e8 | ||
|
|
b807481f1c | ||
|
|
da27de2154 | ||
|
|
6743e4fbb2 | ||
|
|
b489ffaa63 | ||
|
|
6e12499d61 | ||
|
|
f7acbe16ba |
@@ -103,8 +103,26 @@ func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
|
||||
store.createBackupFolders()
|
||||
|
||||
options = store.setupOptions(options)
|
||||
dbPath := store.databasePath()
|
||||
|
||||
return options.BackupPath, store.copyDBFile(store.databasePath(), options.BackupPath)
|
||||
if err := store.Close(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error closing datastore before creating backup: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
|
||||
return options.BackupPath, err
|
||||
}
|
||||
|
||||
if _, err := store.Open(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error opening datastore after creating backup: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return options.BackupPath, nil
|
||||
}
|
||||
|
||||
// RestoreWithOptions previously saved backup for the current Edition with options
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
"TenantID": ""
|
||||
},
|
||||
"ComposeSyntaxMaxVersion": "",
|
||||
"Edge": {
|
||||
"AsyncMode": false,
|
||||
"CommandInterval": 0,
|
||||
"PingInterval": 0,
|
||||
"SnapshotInterval": 0
|
||||
},
|
||||
"EdgeCheckinInterval": 0,
|
||||
"EdgeKey": "",
|
||||
"GroupId": 1,
|
||||
@@ -682,6 +688,12 @@
|
||||
"BlackListedLabels": [],
|
||||
"DisplayDonationHeader": false,
|
||||
"DisplayExternalContributors": false,
|
||||
"Edge": {
|
||||
"AsyncMode": false,
|
||||
"CommandInterval": 0,
|
||||
"PingInterval": 0,
|
||||
"SnapshotInterval": 0
|
||||
},
|
||||
"EdgeAgentCheckinInterval": 5,
|
||||
"EdgePortainerUrl": "",
|
||||
"EnableEdgeComputeFeatures": false,
|
||||
@@ -898,7 +910,7 @@
|
||||
],
|
||||
"version": {
|
||||
"DB_UPDATING": "false",
|
||||
"DB_VERSION": "50",
|
||||
"DB_VERSION": "52",
|
||||
"INSTANCE_ID": "null"
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths)
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, "")
|
||||
return errors.Wrap(err, "failed to remove a stack")
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f
|
||||
|
||||
@@ -807,10 +807,8 @@ 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-20220526210722-e1574867298e h1:gW1Ooaj7RZ9YkwHxesnNEyOB5nUD71FlZ7cdb5h63vw=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410 h1:LjxLd8UGR8ae73ov/vLrt/0jedj/nh98XnONkr8DJj8=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
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/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=
|
||||
|
||||
@@ -80,7 +80,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.14.0
|
||||
// @version 2.14.2
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/portainer/libhelm"
|
||||
@@ -108,7 +107,7 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
|
||||
|
||||
hostURL := "localhost"
|
||||
if !sslSettings.SelfSigned {
|
||||
hostURL = strings.Split(r.Host, ":")[0]
|
||||
hostURL = r.Host
|
||||
}
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(hostURL, endpoint.ID)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
@@ -145,8 +144,7 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
|
||||
}
|
||||
|
||||
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint) clientV1.NamedCluster {
|
||||
hostURL := strings.Split(r.Host, ":")[0]
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(hostURL, endpoint.ID)
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(r.Host, endpoint.ID)
|
||||
return clientV1.NamedCluster{
|
||||
Name: buildClusterName(endpoint.Name),
|
||||
Cluster: clientV1.Cluster{
|
||||
|
||||
@@ -30,6 +30,19 @@ type publicSettingsResponse struct {
|
||||
KubeconfigExpiry string `example:"24h" default:"0"`
|
||||
// Whether team sync is enabled
|
||||
TeamSync bool `json:"TeamSync" example:"true"`
|
||||
|
||||
Edge struct {
|
||||
// Whether the device has been started in edge async mode
|
||||
AsyncMode bool
|
||||
// The ping interval for edge agent - used in edge async mode [seconds]
|
||||
PingInterval int `json:"PingInterval" example:"60"`
|
||||
// The snapshot interval for edge agent - used in edge async mode [seconds]
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
|
||||
// The command list interval for edge agent - used in edge async mode [seconds]
|
||||
CommandInterval int `json:"CommandInterval" example:"60"`
|
||||
// The check in interval for edge agent (in seconds) - used in non async mode [seconds]
|
||||
CheckinInterval int `example:"60"`
|
||||
}
|
||||
}
|
||||
|
||||
// @id SettingsPublic
|
||||
@@ -61,6 +74,13 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
||||
KubeconfigExpiry: appSettings.KubeconfigExpiry,
|
||||
Features: appSettings.FeatureFlagSettings,
|
||||
}
|
||||
|
||||
publicSettings.Edge.AsyncMode = appSettings.Edge.AsyncMode
|
||||
publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval
|
||||
publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval
|
||||
publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval
|
||||
publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval
|
||||
|
||||
//if OAuth authentication is on, compose the related fields from application settings
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
|
||||
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI
|
||||
@@ -75,8 +95,10 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
||||
}
|
||||
}
|
||||
//if LDAP authentication is on, compose the related fields from application settings
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP {
|
||||
publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings) > 0
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP && appSettings.LDAPSettings.GroupSearchSettings != nil {
|
||||
if len(appSettings.LDAPSettings.GroupSearchSettings) > 0 {
|
||||
publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings[0].GroupBaseDN) > 0
|
||||
}
|
||||
}
|
||||
return publicSettings
|
||||
}
|
||||
|
||||
@@ -24,11 +24,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
adminRouter := h.NewRoute().Subrouter()
|
||||
adminRouter.Use(bouncer.AdminAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
teamLeaderRouter := h.NewRoute().Subrouter()
|
||||
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
|
||||
|
||||
adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost)
|
||||
teamLeaderRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
|
||||
teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet)
|
||||
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized}
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")}
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
|
||||
@@ -23,7 +23,7 @@ type (
|
||||
}
|
||||
|
||||
portainerRegistryAuthenticationHeader struct {
|
||||
RegistryId portainer.RegistryID `json:"registryId"`
|
||||
RegistryId *portainer.RegistryID `json:"registryId"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -446,7 +446,20 @@ func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.R
|
||||
return err
|
||||
}
|
||||
|
||||
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, originalHeaderData.RegistryId, accessContext)
|
||||
// delete header and exist function without error if Front End
|
||||
// passes empty json. This is to restore original behavior which
|
||||
// never originally passed this header
|
||||
if string(decodedHeaderData) == "{}" {
|
||||
request.Header.Del("X-Registry-Auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
// only set X-Registry-Auth if registryId is defined
|
||||
if originalHeaderData.RegistryId == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ func FilterRegistries(registries []portainer.Registry, user *portainer.User, tea
|
||||
}
|
||||
|
||||
// FilterEndpoints filters environments(endpoints) based on user role and team memberships.
|
||||
// Non administrator and non-team-leader only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
|
||||
// Non administrator only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
|
||||
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
|
||||
filteredEndpoints := endpoints
|
||||
|
||||
if !context.IsAdmin && !context.IsTeamLeader {
|
||||
if !context.IsAdmin {
|
||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
@@ -101,11 +101,11 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
|
||||
}
|
||||
|
||||
// FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships.
|
||||
// Non administrator users and Non-team-leaders only have access to authorized environment(endpoint) groups.
|
||||
// Non administrator users only have access to authorized environment(endpoint) groups.
|
||||
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup {
|
||||
filteredEndpointGroups := endpointGroups
|
||||
|
||||
if !context.IsAdmin && !context.IsTeamLeader {
|
||||
if !context.IsAdmin {
|
||||
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
|
||||
|
||||
for _, group := range endpointGroups {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// KubeClusterAccessService represents a service that is responsible for centralizing kube cluster access data
|
||||
@@ -94,11 +95,20 @@ func (service *kubeClusterAccessService) IsSecure() bool {
|
||||
// - pass down params to binaries
|
||||
func (service *kubeClusterAccessService) GetData(hostURL string, endpointID portainer.EndpointID) kubernetesClusterAccessData {
|
||||
baseURL := service.baseURL
|
||||
|
||||
// When the api call is internal, the baseURL should not be used.
|
||||
if hostURL == "localhost" {
|
||||
hostURL = hostURL + service.httpsBindAddr
|
||||
baseURL = "/"
|
||||
}
|
||||
|
||||
if baseURL != "/" {
|
||||
baseURL = fmt.Sprintf("/%s/", strings.Trim(baseURL, "/"))
|
||||
}
|
||||
|
||||
clusterURL := hostURL + service.httpsBindAddr + baseURL
|
||||
logrus.Infof("[kubeconfig] [hostURL: %s, httpsBindAddr: %s, baseURL: %s]", hostURL, service.httpsBindAddr, baseURL)
|
||||
|
||||
clusterURL := hostURL + baseURL
|
||||
|
||||
clusterServerURL := fmt.Sprintf("https://%sapi/endpoints/%d/kubernetes", clusterURL, endpointID)
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestKubeClusterAccessService_GetKubeConfigInternal(t *testing.T) {
|
||||
clusterAccessDetails := kcs.GetData("mysite.com", 1)
|
||||
|
||||
wantClusterAccessDetails := kubernetesClusterAccessData{
|
||||
ClusterServerURL: "https://mysite.com:9443/api/endpoints/1/kubernetes",
|
||||
ClusterServerURL: "https://mysite.com/api/endpoints/1/kubernetes",
|
||||
CertificateAuthorityFile: "",
|
||||
CertificateAuthorityData: "",
|
||||
}
|
||||
|
||||
@@ -345,6 +345,17 @@ type (
|
||||
// Whether the device has been trusted or not by the user
|
||||
UserTrusted bool
|
||||
|
||||
Edge struct {
|
||||
// Whether the device has been started in edge async mode
|
||||
AsyncMode bool
|
||||
// The ping interval for edge agent - used in edge async mode [seconds]
|
||||
PingInterval int `json:"PingInterval" example:"60"`
|
||||
// The snapshot interval for edge agent - used in edge async mode [seconds]
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
|
||||
// The command list interval for edge agent - used in edge async mode [seconds]
|
||||
CommandInterval int `json:"CommandInterval" example:"60"`
|
||||
}
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
TLS bool `json:"TLS,omitempty"`
|
||||
@@ -837,6 +848,17 @@ type (
|
||||
// EdgePortainerURL is the URL that is exposed to edge agents
|
||||
EdgePortainerURL string `json:"EdgePortainerUrl"`
|
||||
|
||||
Edge struct {
|
||||
// The command list interval for edge agent - used in edge async mode (in seconds)
|
||||
CommandInterval int `json:"CommandInterval" example:"5"`
|
||||
// The ping interval for edge agent - used in edge async mode (in seconds)
|
||||
PingInterval int `json:"PingInterval" example:"5"`
|
||||
// The snapshot interval for edge agent - used in edge async mode (in seconds)
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"5"`
|
||||
// EdgeAsyncMode enables edge async mode by default
|
||||
AsyncMode bool
|
||||
}
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
DisplayExternalContributors bool
|
||||
@@ -1363,9 +1385,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.14.0"
|
||||
APIVersion = "2.14.2"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 50
|
||||
DBVersion = 52
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
ComposeSyntaxMaxVersion = "3.9"
|
||||
// AssetsServerURL represents the URL of the Portainer asset server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useSettings } from '@/portainer/settings/queries';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
const categories = [
|
||||
'docker',
|
||||
@@ -64,7 +64,9 @@ export function push(
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const telemetryQuery = useSettings((settings) => settings.EnableTelemetry);
|
||||
const telemetryQuery = usePublicSettings({
|
||||
select: (settings) => settings.EnableTelemetry,
|
||||
});
|
||||
|
||||
return { trackEvent: handleTrackEvent };
|
||||
|
||||
|
||||
@@ -20,3 +20,4 @@ import './app.css';
|
||||
|
||||
import './theme.css';
|
||||
import './vendor-override.css';
|
||||
import '../fonts/nomad-icon.css';
|
||||
|
||||
32
app/assets/fonts/nomad-icon.css
Normal file
32
app/assets/fonts/nomad-icon.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* created using https://icomoon.io/app */
|
||||
/* https://stackoverflow.com/a/35092005/681629 */
|
||||
/* for additional icons, we should create a new set that includes the existing icons */
|
||||
|
||||
@font-face {
|
||||
font-family: 'nomad-icon';
|
||||
src: url('nomad-icon/nomad-icon.eot?6tre2n');
|
||||
src: url('nomad-icon/nomad-icon.eot?6tre2n#iefix') format('embedded-opentype'), url('nomad-icon/nomad-icon.ttf?6tre2n') format('truetype'),
|
||||
url('nomad-icon/nomad-icon.woff?6tre2n') format('woff'), url('nomad-icon/nomad-icon.svg?6tre2n#nomad-icon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
.nomad-icon {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'nomad-icon' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.nomad-icon:before {
|
||||
content: '\e900';
|
||||
}
|
||||
BIN
app/assets/fonts/nomad-icon/nomad-icon.eot
Normal file
BIN
app/assets/fonts/nomad-icon/nomad-icon.eot
Normal file
Binary file not shown.
11
app/assets/fonts/nomad-icon/nomad-icon.svg
Normal file
11
app/assets/fonts/nomad-icon/nomad-icon.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Generated by IcoMoon</metadata>
|
||||
<defs>
|
||||
<font id="icomoon" horiz-adv-x="1024">
|
||||
<font-face units-per-em="1024" ascent="960" descent="-64" />
|
||||
<missing-glyph horiz-adv-x="1024" />
|
||||
<glyph unicode=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" glyph-name="nomad_black" d="M507.999 959.562l-443.079-255.649v-511.675l443.079-255.8 443.079 255.8v511.675l-443.079 255.649zM705.402 396.893l-118.079-67.992-142.631 77.435v-163.256l-134.095-84.839v340.865l106.369 65.121 147.617-77.813v166.202l140.894 84.612-0.076-340.336z" />
|
||||
</font></defs></svg>
|
||||
|
After Width: | Height: | Size: 738 B |
BIN
app/assets/fonts/nomad-icon/nomad-icon.ttf
Normal file
BIN
app/assets/fonts/nomad-icon/nomad-icon.ttf
Normal file
Binary file not shown.
BIN
app/assets/fonts/nomad-icon/nomad-icon.woff
Normal file
BIN
app/assets/fonts/nomad-icon/nomad-icon.woff
Normal file
Binary file not shown.
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
AccessControlFormData,
|
||||
ResourceControlResponse,
|
||||
} from '@/portainer/access-control/types';
|
||||
import { AccessControlFormData } from '@/portainer/access-control/types';
|
||||
import { PortainerMetadata } from '@/react/docker/types';
|
||||
|
||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||
|
||||
@@ -21,10 +19,6 @@ export interface ContainerInstanceFormValues {
|
||||
accessControl: AccessControlFormData;
|
||||
}
|
||||
|
||||
interface PortainerMetadata {
|
||||
ResourceControl: ResourceControlResponse;
|
||||
}
|
||||
|
||||
interface Container {
|
||||
name: string;
|
||||
properties: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { ResourceControlOwnership } from '@/portainer/access-control/types';
|
||||
|
||||
import { DockerNetwork } from '../types';
|
||||
|
||||
@@ -113,9 +112,9 @@ function getNetwork(networkName: string): DockerNetwork {
|
||||
},
|
||||
],
|
||||
TeamAccesses: [],
|
||||
Ownership: ResourceControlOwnership.PUBLIC,
|
||||
Public: true,
|
||||
System: false,
|
||||
AdministratorsOnly: true,
|
||||
},
|
||||
},
|
||||
Scope: 'local',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm
|
||||
import { AccessControlPanel } from '@/portainer/access-control/AccessControlPanel/AccessControlPanel';
|
||||
import { ResourceControlType } from '@/portainer/access-control/types';
|
||||
import { DockerContainer } from '@/docker/containers/types';
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
|
||||
import { useNetwork, useDeleteNetwork } from '../queries';
|
||||
import { isSystemNetwork } from '../network.helper';
|
||||
@@ -50,6 +51,12 @@ export function NetworkDetailsView() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const network = networkQuery.data;
|
||||
|
||||
const resourceControl = network.Portainer?.ResourceControl
|
||||
? new ResourceControlViewModel(network.Portainer.ResourceControl)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -77,7 +84,7 @@ export function NetworkDetailsView() {
|
||||
networkId,
|
||||
])
|
||||
}
|
||||
resourceControl={networkQuery.data.Portainer?.ResourceControl}
|
||||
resourceControl={resourceControl}
|
||||
resourceType={ResourceControlType.Network}
|
||||
disableOwnershipChange={isSystemNetwork(networkQuery.data.Name)}
|
||||
resourceId={networkId}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { PortainerMetadata } from '@/react/docker/types';
|
||||
|
||||
import { ContainerId } from '../containers/types';
|
||||
|
||||
@@ -44,7 +44,7 @@ export interface DockerNetwork {
|
||||
Driver: string;
|
||||
Options: IpamOptions;
|
||||
};
|
||||
Portainer: { ResourceControl?: ResourceControlViewModel };
|
||||
Portainer?: PortainerMetadata;
|
||||
Options: NetworkOptions;
|
||||
Containers: NetworkResponseContainers;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,5 @@ export const heartbeat: Column<Environment> = {
|
||||
export function StatusCell({
|
||||
row: { original: environment },
|
||||
}: CellProps<Environment>) {
|
||||
return (
|
||||
<EdgeIndicator
|
||||
checkInInterval={environment.EdgeCheckinInterval}
|
||||
edgeId={environment.EdgeID}
|
||||
lastCheckInDate={environment.LastCheckInDate}
|
||||
queryDate={environment.QueryDate}
|
||||
/>
|
||||
);
|
||||
return <EdgeIndicator environment={environment} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { TableSettingsProvider } from '@/portainer/components/datatables/compone
|
||||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { InformationPanel } from '@/portainer/components/InformationPanel';
|
||||
import { TextTip } from '@/portainer/components/Tip/TextTip';
|
||||
|
||||
import { DataTable } from './Datatable/Datatable';
|
||||
import { TableSettings } from './Datatable/types';
|
||||
@@ -29,6 +31,15 @@ export function WaitingRoomView() {
|
||||
{ label: 'Waiting Room' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
Only environments generated from the AEEC script will appear here,
|
||||
manually added environments and edge devices will bypass the waiting
|
||||
room.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
|
||||
<TableSettingsProvider<TableSettings>
|
||||
defaults={{ pageSize: 10, sortBy: { desc: false, id: 'name' } }}
|
||||
storageKey={storageKey}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class KubeCreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
@@ -13,6 +14,7 @@ class KubeCreateCustomTemplateViewController {
|
||||
];
|
||||
|
||||
this.templates = null;
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.state = {
|
||||
method: 'editor',
|
||||
@@ -53,6 +55,10 @@ class KubeCreateCustomTemplateViewController {
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</file-upload-form>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="$ctrl.state.method === 'editor'"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
|
||||
class KubeEditCustomTemplateViewController {
|
||||
@@ -7,6 +8,8 @@ class KubeEditCustomTemplateViewController {
|
||||
constructor($async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.formValues = null;
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
@@ -60,6 +63,10 @@ class KubeEditCustomTemplateViewController {
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
</web-editor-form>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="true"
|
||||
|
||||
@@ -5,7 +5,7 @@ angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory);
|
||||
/* @ngInject */
|
||||
function HelmFactory($resource, API_ENDPOINT_ENDPOINTS) {
|
||||
const helmUrl = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/helm';
|
||||
const templatesUrl = '/api/templates/helm';
|
||||
const templatesUrl = 'api/templates/helm';
|
||||
|
||||
return $resource(
|
||||
helmUrl,
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
></custom-template-selector>
|
||||
|
||||
<custom-templates-variables-field
|
||||
ng-if="ctrl.state.template"
|
||||
ng-if="ctrl.isTemplateVariablesEnabled && ctrl.state.template"
|
||||
definitions="ctrl.state.template.Variables"
|
||||
value="ctrl.formValues.Variables"
|
||||
on-change="(ctrl.onChangeTemplateVariables)"
|
||||
|
||||
@@ -7,6 +7,7 @@ import PortainerError from '@/portainer/error';
|
||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { buildOption } from '@/portainer/components/BoxSelector';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
@@ -23,6 +24,8 @@ class KubernetesDeployController {
|
||||
this.CustomTemplateService = CustomTemplateService;
|
||||
this.DeployMethod = 'manifest';
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.deployOptions = [
|
||||
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
|
||||
buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
|
||||
@@ -83,6 +86,10 @@ class KubernetesDeployController {
|
||||
}
|
||||
|
||||
renderTemplate() {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rendered = renderTemplate(this.state.templateContent, this.formValues.Variables, this.state.template.Variables);
|
||||
this.onChangeFormValues({ EditorContent: rendered });
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ export class ResourceControlViewModel {
|
||||
this.TeamAccesses = data.TeamAccesses;
|
||||
this.Public = data.Public;
|
||||
this.System = data.System;
|
||||
this.Ownership = determineOwnership(this);
|
||||
this.Ownership = determineOwnership(data);
|
||||
}
|
||||
}
|
||||
|
||||
function determineOwnership(resourceControl: ResourceControlViewModel) {
|
||||
function determineOwnership(resourceControl: ResourceControlResponse) {
|
||||
if (resourceControl.Public) {
|
||||
return ResourceControlOwnership.PUBLIC;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
@@ -6,11 +7,15 @@ import { getFeatureDetails } from './utils';
|
||||
|
||||
export interface Props {
|
||||
featureId?: FeatureId;
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BEFeatureIndicator({
|
||||
featureId,
|
||||
children,
|
||||
showIcon = true,
|
||||
className = '',
|
||||
}: PropsWithChildren<Props>) {
|
||||
const { url, limitedToBE } = getFeatureDetails(featureId);
|
||||
|
||||
@@ -20,14 +25,18 @@ export function BEFeatureIndicator({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="be-indicator"
|
||||
className={clsx('be-indicator', className)}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
<i className="fas fa-briefcase space-right be-indicator-icon" />
|
||||
<span className="be-indicator-label">Business Edition Feature</span>
|
||||
{showIcon && (
|
||||
<i className="fas fa-briefcase space-right be-indicator-icon" />
|
||||
)}
|
||||
<span className="be-indicator-label break-words">
|
||||
Business Edition Feature
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '../Button';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
title?: string;
|
||||
onDismiss?(): void;
|
||||
bodyClassName?: string;
|
||||
wrapperStyle?: Record<string, string>;
|
||||
@@ -23,21 +23,23 @@ export function InformationPanel({
|
||||
<Widget>
|
||||
<WidgetBody className={bodyClassName}>
|
||||
<div style={wrapperStyle}>
|
||||
<div className="col-sm-12 form-section-title">
|
||||
<span style={{ float: 'left' }}>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span
|
||||
className="small"
|
||||
style={{ float: 'right' }}
|
||||
ng-if="dismissAction"
|
||||
>
|
||||
<Button color="link" onClick={() => onDismiss()}>
|
||||
<i className="fa fa-times" /> dismiss
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group">{children}</div>
|
||||
{title && (
|
||||
<div className="col-sm-12 form-section-title">
|
||||
<span style={{ float: 'left' }}>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span
|
||||
className="small"
|
||||
style={{ float: 'right' }}
|
||||
ng-if="dismissAction"
|
||||
>
|
||||
<Button color="link" onClick={() => onDismiss()}>
|
||||
<i className="fa fa-times" /> dismiss
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { react2angular } from '@/react-tools/react2angular';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
export function PasswordCheckHint() {
|
||||
interface Props {
|
||||
passwordValid: boolean;
|
||||
forceChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export function PasswordCheckHint({
|
||||
passwordValid,
|
||||
forceChangePassword,
|
||||
}: Props) {
|
||||
const settingsQuery = usePublicSettings();
|
||||
const minPasswordLength = settingsQuery.data?.RequiredPasswordLength;
|
||||
|
||||
@@ -12,10 +20,18 @@ export function PasswordCheckHint() {
|
||||
className="fa fa-exclamation-triangle orange-icon space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{forceChangePassword &&
|
||||
'An administrator has changed your password requirements, '}
|
||||
The password must be at least {minPasswordLength} characters long.
|
||||
{passwordValid && (
|
||||
<i className="fa fa-check green-icon space-left" aria-hidden="true" />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, []);
|
||||
export const PasswordCheckHintAngular = react2angular(PasswordCheckHint, [
|
||||
'passwordValid',
|
||||
'forceChangePassword',
|
||||
]);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="available"
|
||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
||||
retrieve-page="$ctrl.getAvailableEndpoints"
|
||||
dataset="$ctrl.endpoints.available"
|
||||
entry-click="$ctrl.associateEndpoint"
|
||||
pagination-state="$ctrl.state.available"
|
||||
@@ -34,7 +34,7 @@
|
||||
loaded="$ctrl.loaded"
|
||||
page-type="$ctrl.pageType"
|
||||
table-type="associated"
|
||||
retrieve-page="$ctrl.getPaginatedEndpoints"
|
||||
retrieve-page="$ctrl.getAssociatedEndpoints"
|
||||
dataset="$ctrl.endpoints.associated"
|
||||
entry-click="$ctrl.dissociateEndpoint"
|
||||
pagination-state="$ctrl.state.associated"
|
||||
|
||||
@@ -27,7 +27,7 @@ class AssoicatedEndpointsSelectorController {
|
||||
available: null,
|
||||
};
|
||||
|
||||
this.getEndpoints = this.getEndpoints.bind(this);
|
||||
this.getAvailableEndpoints = this.getAvailableEndpoints.bind(this);
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.getAssociatedEndpoints = this.getAssociatedEndpoints.bind(this);
|
||||
this.getAssociatedEndpointsAsync = this.getAssociatedEndpointsAsync.bind(this);
|
||||
@@ -47,10 +47,10 @@ class AssoicatedEndpointsSelectorController {
|
||||
|
||||
loadData() {
|
||||
this.getAssociatedEndpoints();
|
||||
this.getEndpoints();
|
||||
this.getAvailableEndpoints();
|
||||
}
|
||||
|
||||
getEndpoints() {
|
||||
getAvailableEndpoints() {
|
||||
return this.$async(this.getEndpointsAsync);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.setRefferer()" ui-sref="portainer.wizard.endpoints" data-cy="endpoint-addEndpointButton">
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.setReferrer()" ui-sref="portainer.wizard.endpoints" data-cy="endpoint-addEndpointButton">
|
||||
<i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,8 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
|
||||
this.paginationChanged();
|
||||
};
|
||||
|
||||
this.setRefferer = function () {
|
||||
window.localStorage.setItem('wizardRefferer', 'environments');
|
||||
this.setReferrer = function () {
|
||||
window.localStorage.setItem('wizardReferrer', 'environments');
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,26 +29,29 @@ export function FormControl({
|
||||
required,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className={clsx('form-group', styles.container)}>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
|
||||
>
|
||||
{label}
|
||||
<>
|
||||
<div className={clsx('form-group', styles.container)}>
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
|
||||
>
|
||||
{label}
|
||||
|
||||
{required && <span className="text-danger">*</span>}
|
||||
{required && <span className="text-danger">*</span>}
|
||||
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</label>
|
||||
|
||||
<div className={sizeClassChildren(size)}>{children}</div>
|
||||
{tooltip && <Tooltip message={tooltip} />}
|
||||
</label>
|
||||
|
||||
<div className={sizeClassChildren(size)}>{children}</div>
|
||||
</div>
|
||||
{errors && (
|
||||
<div className="col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
<div className="form-group">
|
||||
<div className="col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface KubernetesSettings {
|
||||
Snapshots?: KubernetesSnapshot[] | null;
|
||||
}
|
||||
|
||||
export type EnvironmentEdge = {
|
||||
AsyncMode: boolean;
|
||||
PingInterval: number;
|
||||
SnapshotInterval: number;
|
||||
CommandInterval: number;
|
||||
};
|
||||
|
||||
export type Environment = {
|
||||
Id: EnvironmentId;
|
||||
Type: EnvironmentType;
|
||||
@@ -73,6 +80,7 @@ export type Environment = {
|
||||
IsEdgeDevice?: boolean;
|
||||
UserTrusted: boolean;
|
||||
AMTDeviceGUID?: string;
|
||||
Edge: EnvironmentEdge;
|
||||
};
|
||||
/**
|
||||
* TS reference of endpoint_create.go#EndpointCreationType iota
|
||||
@@ -83,6 +91,7 @@ export enum EnvironmentCreationTypes {
|
||||
AzureEnvironment,
|
||||
EdgeAgentEnvironment,
|
||||
LocalKubernetesEnvironment,
|
||||
KubeConfigEnvironment,
|
||||
}
|
||||
|
||||
export enum PlatformType {
|
||||
|
||||
@@ -12,6 +12,9 @@ export enum FeatureState {
|
||||
export enum FeatureId {
|
||||
K8S_RESOURCE_POOL_LB_QUOTA = 'k8s-resourcepool-Ibquota',
|
||||
K8S_RESOURCE_POOL_STORAGE_QUOTA = 'k8s-resourcepool-storagequota',
|
||||
K8S_CREATE_FROM_KUBECONFIG = 'k8s-create-from-kubeconfig',
|
||||
KAAS_PROVISIONING = 'kaas-provisioning',
|
||||
NOMAD = 'nomad',
|
||||
RBAC_ROLES = 'rbac-roles',
|
||||
REGISTRY_MANAGEMENT = 'registry-management',
|
||||
K8S_SETUP_DEFAULT = 'k8s-setup-default',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Edition, FeatureId, FeatureState } from './enums';
|
||||
|
||||
export const isBE = process.env.PORTAINER_EDITION === 'BE';
|
||||
interface ServiceState {
|
||||
currentEdition: Edition;
|
||||
features: Record<FeatureId, Edition>;
|
||||
@@ -16,6 +17,9 @@ export async function init(edition: Edition) {
|
||||
const features = {
|
||||
[FeatureId.K8S_RESOURCE_POOL_LB_QUOTA]: Edition.BE,
|
||||
[FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA]: Edition.BE,
|
||||
[FeatureId.K8S_CREATE_FROM_KUBECONFIG]: Edition.BE,
|
||||
[FeatureId.KAAS_PROVISIONING]: Edition.BE,
|
||||
[FeatureId.NOMAD]: Edition.BE,
|
||||
[FeatureId.ACTIVITY_AUDIT]: Edition.BE,
|
||||
[FeatureId.EXTERNAL_AUTH_LDAP]: Edition.BE,
|
||||
[FeatureId.HIDE_INTERNAL_AUTH]: Edition.BE,
|
||||
|
||||
@@ -22,22 +22,3 @@ export function withFileSize(fileValidation: FileSchema, maxSize: number) {
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
}
|
||||
|
||||
export function withFileType(
|
||||
fileValidation: FileSchema,
|
||||
fileTypes: File['type'][]
|
||||
) {
|
||||
return fileValidation.test(
|
||||
'file-type',
|
||||
'Selected file has unsupported format.',
|
||||
validateFileType
|
||||
);
|
||||
|
||||
function validateFileType(file?: File) {
|
||||
if (!file) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return fileTypes.includes(file.type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
|
||||
test('when edge id is not set, should show unassociated label', () => {
|
||||
const { queryByLabelText } = renderComponent();
|
||||
test('when edge id is not set, should show unassociated label', async () => {
|
||||
const { queryByLabelText } = await renderComponent();
|
||||
|
||||
const unassociatedLabel = queryByLabelText('unassociated');
|
||||
|
||||
expect(unassociatedLabel).toBeVisible();
|
||||
});
|
||||
|
||||
test('given edge id and last checkin is set, should show heartbeat', () => {
|
||||
const { queryByLabelText } = renderComponent('id', 1);
|
||||
// test('given edge id and last checkin is set, should show heartbeat', async () => {
|
||||
// const { queryByLabelText } = await renderComponent('id', 1);
|
||||
|
||||
expect(queryByLabelText('edge-heartbeat')).toBeVisible();
|
||||
expect(queryByLabelText('edge-last-checkin')).toBeVisible();
|
||||
});
|
||||
// expect(queryByLabelText('edge-heartbeat')).toBeVisible();
|
||||
// expect(queryByLabelText('edge-last-checkin')).toBeVisible();
|
||||
// });
|
||||
|
||||
function renderComponent(
|
||||
async function renderComponent(
|
||||
edgeId = '',
|
||||
lastCheckInDate = 0,
|
||||
checkInInterval = 0,
|
||||
queryDate = 0
|
||||
) {
|
||||
return render(
|
||||
<EdgeIndicator
|
||||
edgeId={edgeId}
|
||||
lastCheckInDate={lastCheckInDate}
|
||||
checkInInterval={checkInInterval}
|
||||
queryDate={queryDate}
|
||||
showLastCheckInDate
|
||||
/>
|
||||
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
|
||||
|
||||
const environment = createMockEnvironment();
|
||||
|
||||
environment.EdgeID = edgeId;
|
||||
environment.LastCheckInDate = lastCheckInDate;
|
||||
environment.EdgeCheckinInterval = checkInInterval;
|
||||
environment.QueryDate = queryDate;
|
||||
|
||||
const queries = renderWithQueryClient(
|
||||
<EdgeIndicator environment={environment} showLastCheckInDate />
|
||||
);
|
||||
|
||||
await expect(queries.findByRole('status')).resolves.toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,114 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
|
||||
interface Props {
|
||||
checkInInterval?: number;
|
||||
edgeId?: string;
|
||||
queryDate?: number;
|
||||
lastCheckInDate?: number;
|
||||
showLastCheckInDate?: boolean;
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function EdgeIndicator({
|
||||
edgeId,
|
||||
lastCheckInDate,
|
||||
checkInInterval,
|
||||
queryDate,
|
||||
environment,
|
||||
|
||||
showLastCheckInDate = false,
|
||||
}: Props) {
|
||||
if (!edgeId) {
|
||||
const associated = !!environment.EdgeID;
|
||||
|
||||
const isValid = useHasHeartbeat(environment, associated);
|
||||
|
||||
if (isValid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!associated) {
|
||||
return (
|
||||
<span className="label label-default" aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span className="label label-default" aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// give checkIn some wiggle room
|
||||
let isCheckValid = false;
|
||||
if (checkInInterval && queryDate && lastCheckInDate) {
|
||||
isCheckValid = queryDate - lastCheckInDate <= checkInInterval * 2 + 20;
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span role="status" aria-label="edge-status">
|
||||
<span
|
||||
className={clsx('label', {
|
||||
'label-danger': !isCheckValid,
|
||||
'label-success': isCheckValid,
|
||||
'label-danger': !isValid,
|
||||
'label-success': isValid,
|
||||
})}
|
||||
aria-label="edge-heartbeat"
|
||||
>
|
||||
heartbeat
|
||||
</span>
|
||||
|
||||
{showLastCheckInDate && !!lastCheckInDate && (
|
||||
{showLastCheckInDate && !!environment.LastCheckInDate && (
|
||||
<span
|
||||
className="space-left small text-muted"
|
||||
aria-label="edge-last-checkin"
|
||||
>
|
||||
{isoDateFromTimestamp(lastCheckInDate)}
|
||||
{isoDateFromTimestamp(environment.LastCheckInDate)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useHasHeartbeat(environment: Environment, associated: boolean) {
|
||||
const settingsQuery = usePublicSettings({ enabled: associated });
|
||||
|
||||
if (!associated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { LastCheckInDate, QueryDate } = environment;
|
||||
|
||||
const settings = settingsQuery.data;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkInInterval = getCheckinInterval(environment, settings);
|
||||
|
||||
if (checkInInterval && QueryDate && LastCheckInDate) {
|
||||
return QueryDate - LastCheckInDate <= checkInInterval * 2 + 20;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getCheckinInterval(
|
||||
environment: Environment,
|
||||
settings: PublicSettingsViewModel
|
||||
) {
|
||||
const asyncMode = environment.Edge.AsyncMode;
|
||||
|
||||
if (asyncMode) {
|
||||
const intervals = [
|
||||
environment.Edge.PingInterval > 0
|
||||
? environment.Edge.PingInterval
|
||||
: settings.Edge.PingInterval,
|
||||
environment.Edge.SnapshotInterval > 0
|
||||
? environment.Edge.SnapshotInterval
|
||||
: settings.Edge.SnapshotInterval,
|
||||
environment.Edge.CommandInterval > 0
|
||||
? environment.Edge.CommandInterval
|
||||
: settings.Edge.CommandInterval,
|
||||
].filter((n) => n > 0);
|
||||
|
||||
return intervals.length > 0 ? Math.min(...intervals) : 60;
|
||||
}
|
||||
|
||||
if (
|
||||
!environment.EdgeCheckinInterval ||
|
||||
environment.EdgeCheckinInterval === 0
|
||||
) {
|
||||
return settings.Edge.CheckinInterval;
|
||||
}
|
||||
|
||||
return environment.EdgeCheckinInterval;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EnvironmentStatus,
|
||||
EnvironmentType,
|
||||
} from '@/portainer/environments/types';
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
@@ -57,19 +58,9 @@ KubernetesEdgeEnvironment.args = {
|
||||
};
|
||||
|
||||
function mockEnvironment(type: EnvironmentType): Environment {
|
||||
return {
|
||||
Id: 1,
|
||||
Name: 'environment',
|
||||
GroupId: 1,
|
||||
Snapshots: [],
|
||||
Status: EnvironmentStatus.Up,
|
||||
TagIds: [],
|
||||
Type: type,
|
||||
Kubernetes: {
|
||||
Snapshots: [],
|
||||
},
|
||||
URL: 'url',
|
||||
UserTrusted: false,
|
||||
EdgeKey: '',
|
||||
};
|
||||
const env = createMockEnvironment();
|
||||
env.Type = type;
|
||||
env.Status = EnvironmentStatus.Up;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -6,25 +6,14 @@ import { Environment } from '@/portainer/environments/types';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { Tag } from '@/portainer/tags/types';
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
|
||||
import { EnvironmentItem } from './EnvironmentItem';
|
||||
|
||||
test('loads component', async () => {
|
||||
const env: Environment = {
|
||||
TagIds: [],
|
||||
GroupId: 1,
|
||||
Type: 1,
|
||||
Name: 'environment',
|
||||
Status: 1,
|
||||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: { Snapshots: [] },
|
||||
Id: 3,
|
||||
UserTrusted: false,
|
||||
EdgeKey: '',
|
||||
};
|
||||
const env = createMockEnvironment();
|
||||
const { getByText } = renderComponent(env);
|
||||
|
||||
expect(getByText(env.Name)).toBeInTheDocument();
|
||||
@@ -34,19 +23,8 @@ test('shows group name', async () => {
|
||||
const groupName = 'group-name';
|
||||
const groupId: EnvironmentGroupId = 14;
|
||||
|
||||
const env: Environment = {
|
||||
TagIds: [],
|
||||
GroupId: groupId,
|
||||
Type: 1,
|
||||
Name: 'environment',
|
||||
Status: 1,
|
||||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: { Snapshots: [] },
|
||||
Id: 3,
|
||||
UserTrusted: false,
|
||||
EdgeKey: '',
|
||||
};
|
||||
const env = createMockEnvironment();
|
||||
env.GroupId = groupId;
|
||||
|
||||
const { findByText } = renderComponent(env, { Name: groupName });
|
||||
|
||||
|
||||
@@ -68,10 +68,8 @@ export function EnvironmentItem({ environment, onClick, groupName }: Props) {
|
||||
<span className="space-left blocklist-item-subtitle">
|
||||
{isEdge ? (
|
||||
<EdgeIndicator
|
||||
edgeId={environment.EdgeID}
|
||||
checkInInterval={environment.EdgeCheckinInterval}
|
||||
lastCheckInDate={environment.LastCheckInDate}
|
||||
queryDate={environment.QueryDate}
|
||||
environment={environment}
|
||||
showLastCheckInDate
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -34,6 +34,8 @@ export function PublicSettingsViewModel(settings) {
|
||||
this.EnableTelemetry = settings.EnableTelemetry;
|
||||
this.OAuthLogoutURI = settings.OAuthLogoutURI;
|
||||
this.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||
this.Features = settings.Features;
|
||||
this.Edge = new EdgeSettingsViewModel(settings.Edge);
|
||||
}
|
||||
|
||||
export function InternalAuthSettingsViewModel(data) {
|
||||
@@ -75,3 +77,10 @@ export function OAuthSettingsViewModel(data) {
|
||||
this.SSO = data.SSO;
|
||||
this.LogoutURI = data.LogoutURI;
|
||||
}
|
||||
|
||||
export function EdgeSettingsViewModel(data = {}) {
|
||||
this.CheckinInterval = data.CheckinInterval;
|
||||
this.PingInterval = data.PingInterval;
|
||||
this.SnapshotInterval = data.SnapshotInterval;
|
||||
this.CommandInterval = data.CommandInterval;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ function StateManagerFactory(
|
||||
UI: {
|
||||
dismissedInfoPanels: {},
|
||||
dismissedInfoHash: '',
|
||||
timesPasswordChangeSkipped: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -49,13 +50,13 @@ function StateManagerFactory(
|
||||
};
|
||||
|
||||
manager.setPasswordChangeSkipped = function (userID) {
|
||||
state.UI.timesPasswordChangeSkipped = state.UI.timesPasswordChangeSkipped || {};
|
||||
state.UI.instanceId = state.UI.instanceId || state.application.instanceId;
|
||||
state.UI.timesPasswordChangeSkipped[userID] = state.UI.timesPasswordChangeSkipped[userID] + 1 || 1;
|
||||
LocalStorage.storeUIState(state.UI);
|
||||
};
|
||||
|
||||
manager.resetPasswordChangeSkips = function (userID) {
|
||||
if (state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0;
|
||||
if (state.UI.timesPasswordChangeSkipped[userID]) state.UI.timesPasswordChangeSkipped[userID] = 0;
|
||||
LocalStorage.storeUIState(state.UI);
|
||||
};
|
||||
|
||||
@@ -141,11 +142,6 @@ function StateManagerFactory(
|
||||
manager.initialize = initialize;
|
||||
async function initialize() {
|
||||
return $async(async () => {
|
||||
const UIState = LocalStorage.getUIState();
|
||||
if (UIState) {
|
||||
state.UI = UIState;
|
||||
}
|
||||
|
||||
const endpointState = LocalStorage.getEndpointState();
|
||||
if (endpointState) {
|
||||
state.endpoint = endpointState;
|
||||
@@ -158,6 +154,16 @@ function StateManagerFactory(
|
||||
await loadApplicationState();
|
||||
}
|
||||
|
||||
const UIState = LocalStorage.getUIState();
|
||||
if (UIState) {
|
||||
state.UI = UIState;
|
||||
if (state.UI.instanceId && state.UI.instanceId !== state.application.instanceId) {
|
||||
state.UI.instanceId = state.application.instanceId;
|
||||
state.UI.timesPasswordChangeSkipped = {};
|
||||
LocalStorage.storeUIState(state.UI);
|
||||
}
|
||||
}
|
||||
|
||||
state.loading = false;
|
||||
$analytics.setPortainerStatus(state.application.instanceId, state.application.version);
|
||||
$analytics.setOptOut(!state.application.enableTelemetry);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { react2angular } from '@/react-tools/react2angular';
|
||||
import { confirm } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { SaveAuthSettingsButton } from '../components/SaveAuthSettingsButton';
|
||||
import { Settings } from '../../types';
|
||||
@@ -19,6 +20,27 @@ export function InternalAuth({
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
function onSubmit() {
|
||||
if (value.RequiredPasswordLength < 10) {
|
||||
confirm({
|
||||
title: 'Allow weak passwords?',
|
||||
message:
|
||||
'You have set an insecure minimum password length. This could leave your system vulnerable to attack, are you sure?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
callback: function onConfirm(confirmed) {
|
||||
if (confirmed) onSaveSettings();
|
||||
},
|
||||
});
|
||||
} else {
|
||||
onSaveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Information</FormSectionTitle>
|
||||
@@ -34,7 +56,7 @@ export function InternalAuth({
|
||||
|
||||
<div className="form-group">
|
||||
<PasswordLengthSlider
|
||||
min={8}
|
||||
min={1}
|
||||
max={18}
|
||||
step={1}
|
||||
value={value.RequiredPasswordLength}
|
||||
@@ -42,7 +64,7 @@ export function InternalAuth({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SaveAuthSettingsButton onSubmit={onSaveSettings} isLoading={isLoading} />
|
||||
<SaveAuthSettingsButton onSubmit={onSubmit} isLoading={isLoading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { PublicSettingsViewModel } from '../models/settings';
|
||||
|
||||
import {
|
||||
publicSettings,
|
||||
getSettings,
|
||||
@@ -9,17 +11,29 @@ import {
|
||||
} from './settings.service';
|
||||
import { Settings } from './types';
|
||||
|
||||
export function usePublicSettings() {
|
||||
export function usePublicSettings<T = PublicSettingsViewModel>({
|
||||
enabled,
|
||||
select,
|
||||
}: {
|
||||
select?: (settings: PublicSettingsViewModel) => T;
|
||||
enabled?: boolean;
|
||||
} = {}) {
|
||||
return useQuery(['settings', 'public'], () => publicSettings(), {
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to retrieve settings');
|
||||
},
|
||||
select,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettings<T = Settings>(select?: (settings: Settings) => T) {
|
||||
export function useSettings<T = Settings>(
|
||||
select?: (settings: Settings) => T,
|
||||
enabled?: boolean
|
||||
) {
|
||||
return useQuery(['settings'], getSettings, {
|
||||
select,
|
||||
enabled,
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
|
||||
@@ -125,4 +125,10 @@ export interface Settings {
|
||||
AllowStackManagementForRegularUsers: boolean;
|
||||
AllowDeviceMappingForRegularUsers: boolean;
|
||||
AllowContainerCapabilitiesForRegularUsers: boolean;
|
||||
Edge: {
|
||||
PingInterval: number;
|
||||
SnapshotInterval: number;
|
||||
CommandInterval: number;
|
||||
AsyncMode: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function CreateAccessToken({
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<div>
|
||||
<div className="form-horizontal">
|
||||
<FormControl
|
||||
inputId="input"
|
||||
label={t('Description')}
|
||||
|
||||
@@ -42,19 +42,12 @@
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input type="password" class="form-control" ng-model="formValues.confirmPassword" id="confirm_password" />
|
||||
<span class="input-group-addon"
|
||||
><i
|
||||
ng-class="
|
||||
{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[
|
||||
form.new_password.$viewValue !== '' && form.new_password.$viewValue === formValues.confirmPassword
|
||||
]
|
||||
"
|
||||
aria-hidden="true"
|
||||
></i
|
||||
><i ng-class="{ true: 'fa fa-check green-icon', false: 'fa fa-times red-icon' }[formValues.newPassword === formValues.confirmPassword]" aria-hidden="true"></i
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<password-check-hint ng-if="!form.new_password.$valid || (forceChangePassword && !formValues.newPassword)"></password-check-hint>
|
||||
<password-check-hint password-valid="form.new_password.$valid && formValues.newPassword" force-change-password="forceChangePassword"></password-check-hint>
|
||||
<div ng-if="userRole === 1">
|
||||
<p class="text-muted">
|
||||
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true"></i>
|
||||
@@ -67,7 +60,7 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="isDemoUser || (AuthenticationMethod !== 1 && !initialUser) || !formValues.currentPassword || !formValues.newPassword || !formValues.confirmPassword || form.$invalid || form.new_password.$viewValue !== formValues.confirmPassword"
|
||||
ng-disabled="isDemoUser || (AuthenticationMethod !== 1 && !isInitialAdmin) || !formValues.currentPassword || !formValues.newPassword || form.$invalid || formValues.newPassword !== formValues.confirmPassword"
|
||||
ng-click="updatePassword()"
|
||||
>
|
||||
Update password
|
||||
@@ -75,11 +68,11 @@
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-click="skipPasswordChange()" ng-if="forceChangePassword && timesPasswordChangeSkipped < 2"
|
||||
>Remind me later</button
|
||||
>
|
||||
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 2 && !initialUser">
|
||||
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 2 && !isInitialAdmin">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
You cannot change your password when using LDAP authentication.
|
||||
</span>
|
||||
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 3 && !initialUser">
|
||||
<span class="text-muted small" style="margin-left: 5px" ng-if="AuthenticationMethod === 3 && !isInitialAdmin">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
You cannot change your password when using OAuth authentication.
|
||||
</span>
|
||||
|
||||
@@ -22,7 +22,7 @@ angular.module('portainer.app').controller('AccountController', [
|
||||
try {
|
||||
await UserService.updateUserPassword($scope.userID, $scope.formValues.currentPassword, $scope.formValues.newPassword);
|
||||
Notifications.success('Success', 'Password successfully updated');
|
||||
StateManager.resetPasswordChangeSkips($scope.userID);
|
||||
StateManager.resetPasswordChangeSkips($scope.userID.toString());
|
||||
$scope.forceChangePassword = false;
|
||||
$state.go('portainer.logout');
|
||||
} catch (err) {
|
||||
@@ -34,7 +34,7 @@ angular.module('portainer.app').controller('AccountController', [
|
||||
$scope.skipPasswordChange = async function () {
|
||||
try {
|
||||
if ($scope.userCanSkip()) {
|
||||
StateManager.setPasswordChangeSkipped($scope.userID);
|
||||
StateManager.setPasswordChangeSkipped($scope.userID.toString());
|
||||
$scope.forceChangePassword = false;
|
||||
$state.go('portainer.home');
|
||||
}
|
||||
@@ -48,8 +48,13 @@ angular.module('portainer.app').controller('AccountController', [
|
||||
};
|
||||
|
||||
this.uiCanExit = (newTransition) => {
|
||||
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
|
||||
return true;
|
||||
if (newTransition) {
|
||||
if ($scope.userRole === 1 && newTransition.to().name === 'portainer.settings.authentication') {
|
||||
return true;
|
||||
}
|
||||
if (newTransition.to().name === 'portainer.logout') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ($scope.forceChangePassword) {
|
||||
ModalService.confirmForceChangePassword();
|
||||
@@ -113,6 +118,7 @@ angular.module('portainer.app').controller('AccountController', [
|
||||
$scope.userID = userDetails.ID;
|
||||
$scope.userRole = Authentication.getUserDetails().role;
|
||||
$scope.forceChangePassword = userDetails.forceChangePassword;
|
||||
$scope.isInitialAdmin = userDetails.ID === 1;
|
||||
|
||||
if (state.application.demoEnvironment.enabled) {
|
||||
$scope.isDemoUser = state.application.demoEnvironment.users.includes($scope.userID);
|
||||
@@ -127,11 +133,13 @@ angular.module('portainer.app').controller('AccountController', [
|
||||
$scope.AuthenticationMethod = data.AuthenticationMethod;
|
||||
|
||||
if (state.UI.requiredPasswordLength && state.UI.requiredPasswordLength !== data.RequiredPasswordLength) {
|
||||
StateManager.clearPasswordChangeSkips($scope.userID);
|
||||
StateManager.clearPasswordChangeSkips();
|
||||
}
|
||||
|
||||
$scope.timesPasswordChangeSkipped =
|
||||
state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID] ? state.UI.timesPasswordChangeSkipped[$scope.userID] : 0;
|
||||
state.UI.timesPasswordChangeSkipped && state.UI.timesPasswordChangeSkipped[$scope.userID.toString()]
|
||||
? state.UI.timesPasswordChangeSkipped[$scope.userID.toString()]
|
||||
: 0;
|
||||
|
||||
$scope.requiredPasswordLength = data.RequiredPasswordLength;
|
||||
StateManager.setRequiredPasswordLength(data.RequiredPasswordLength);
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
</div>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="$ctrl.state.Method === 'editor'"
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'lodash';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
@@ -20,6 +21,8 @@ class CreateCustomTemplateViewController {
|
||||
StateManager,
|
||||
});
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.formValues = {
|
||||
Title: '',
|
||||
FileContent: '',
|
||||
@@ -176,6 +179,10 @@ class CreateCustomTemplateViewController {
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
>
|
||||
<advanced-form>
|
||||
<custom-templates-variables-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
definitions="$ctrl.state.selectedTemplate.Variables"
|
||||
value="$ctrl.formValues.variables"
|
||||
on-change="($ctrl.onChangeTemplateVariables)"
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'lodash-es';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class CustomTemplatesViewController {
|
||||
/* @ngInject */
|
||||
@@ -34,6 +35,8 @@ class CustomTemplatesViewController {
|
||||
this.StateManager = StateManager;
|
||||
this.StackService = StackService;
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.DOCKER_STANDALONE = 'DOCKER_STANDALONE';
|
||||
this.DOCKER_SWARM_MODE = 'DOCKER_SWARM_MODE';
|
||||
|
||||
@@ -119,6 +122,10 @@ class CustomTemplatesViewController {
|
||||
}
|
||||
|
||||
renderTemplate() {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileContent = renderTemplate(this.state.templateContent, this.formValues.variables, this.state.selectedTemplate.Variables);
|
||||
this.onChangeFormValues({ fileContent });
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
</div>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="true"
|
||||
|
||||
@@ -3,12 +3,15 @@ import { ResourceControlViewModel } from '@/portainer/access-control/models/Reso
|
||||
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
class EditCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, $window, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.isTemplateVariablesEnabled = isBE;
|
||||
|
||||
this.formValues = null;
|
||||
this.state = {
|
||||
formValidationError: '',
|
||||
@@ -127,6 +130,10 @@ class EditCustomTemplateViewController {
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
edge-info="{ key: endpoint.EdgeKey, id: endpoint.EdgeID }"
|
||||
commands="state.edgeScriptCommands"
|
||||
is-nomad-token-visible="state.showNomad"
|
||||
hide-async-mode="!endpoint.IsEdgeDevice"
|
||||
></edge-script-form>
|
||||
|
||||
<span class="small text-muted">
|
||||
|
||||
@@ -67,10 +67,11 @@
|
||||
<!-- !confirm-password-input -->
|
||||
<!-- note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 text-muted" ng-if="!form.password.$valid">
|
||||
<div class="col-sm-12 text-muted">
|
||||
<p>
|
||||
<i class="fa fa-exclamation-triangle orange-icon space-right" aria-hidden="true"></i>
|
||||
<span>The password must be at least {{ requiredPasswordLength }} characters long.</span>
|
||||
<i class="fa fa-check green-icon space-left" aria-hidden="true" ng-if="form.password.$valid && formValues.Password"></i>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label="'Use custom logo'"
|
||||
value="formValues.customLogo"
|
||||
checked="formValues.customLogo"
|
||||
name="'toggle_logo'"
|
||||
disabled="state.isDemo"
|
||||
on-change="(onToggleCustomLogo)"
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
label="'Allow the collection of anonymous statistics'"
|
||||
value="formValues.enableTelemetry"
|
||||
checked="formValues.enableTelemetry"
|
||||
name="'toggle_enableTelemetry'"
|
||||
on-change="(onToggleEnableTelemetry)"
|
||||
disabled="state.isDemo"
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
children-paths="['portainer.users.user' ,'portainer.teams' ,'portainer.teams.team' ,'portainer.roles' ,'portainer.roles.role' ,'portainer.roles.new']"
|
||||
>
|
||||
<sidebar-menu-item path="portainer.teams" class-name="sidebar-sublist" data-cy="portainerSidebar-teams" title="Teams">Teams</sidebar-menu-item>
|
||||
<sidebar-menu-item path="portainer.roles" class-name="sidebar-sublist" data-cy="portainerSidebar-roles" title="Roles">Roles</sidebar-menu-item>
|
||||
<sidebar-menu-item ng-if="isAdmin" path="portainer.roles" class-name="sidebar-sublist" data-cy="portainerSidebar-roles" title="Roles">Roles</sidebar-menu-item>
|
||||
</sidebar-menu>
|
||||
|
||||
<div ng-if="isAdmin">
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AccessControlFormData } from '@/portainer/components/accessControlForm/
|
||||
import { STACK_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { RepositoryMechanismTypes } from '@/kubernetes/models/deploy';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { isBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
|
||||
angular
|
||||
.module('portainer.app')
|
||||
@@ -31,6 +33,8 @@ angular
|
||||
endpoint
|
||||
) {
|
||||
$scope.onChangeTemplateId = onChangeTemplateId;
|
||||
$scope.onChangeTemplateVariables = onChangeTemplateVariables;
|
||||
$scope.isTemplateVariablesEnabled = isBE;
|
||||
$scope.buildAnalyticsProperties = buildAnalyticsProperties;
|
||||
$scope.stackWebhookFeature = FeatureId.STACK_WEBHOOK;
|
||||
$scope.STACK_NAME_VALIDATION_REGEX = STACK_NAME_VALIDATION_REGEX;
|
||||
@@ -53,6 +57,7 @@ angular
|
||||
RepositoryMechanism: RepositoryMechanismTypes.INTERVAL,
|
||||
RepositoryFetchInterval: '5m',
|
||||
RepositoryWebhookURL: WebhookHelper.returnStackWebhookUrl(uuidv4()),
|
||||
Variables: {},
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
@@ -265,11 +270,12 @@ angular
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onChangeFileContent = function onChangeFileContent(value) {
|
||||
$scope.onChangeFileContent = onChangeFileContent;
|
||||
function onChangeFileContent(value) {
|
||||
$scope.formValues.StackFileContent = value;
|
||||
$scope.state.editorYamlValidationError = StackHelper.validateYAML($scope.formValues.StackFileContent, $scope.containerNames);
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
}
|
||||
|
||||
async function onFileLoadAsync(event) {
|
||||
$scope.state.uploadYamlValidationError = StackHelper.validateYAML(event.target.result, $scope.containerNames);
|
||||
@@ -292,18 +298,38 @@ angular
|
||||
|
||||
function onChangeTemplateId(templateId, template) {
|
||||
return $async(async () => {
|
||||
if (!template || ($scope.state.selectedTemplateId === templateId && $scope.state.selectedTemplate === template)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$scope.state.selectedTemplateId = templateId;
|
||||
$scope.state.selectedTemplate = template;
|
||||
|
||||
const fileContent = await CustomTemplateService.customTemplateFile(templateId);
|
||||
$scope.onChangeFileContent(fileContent);
|
||||
$scope.state.templateContent = fileContent;
|
||||
onChangeFileContent(fileContent);
|
||||
|
||||
if (template.Variables && template.Variables.length > 0) {
|
||||
const variables = Object.fromEntries(template.Variables.map((variable) => [variable.name, '']));
|
||||
onChangeTemplateVariables(variables);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Custom Template file');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeTemplateVariables(value) {
|
||||
onChangeFormValues({ Variables: value });
|
||||
|
||||
if (!$scope.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
const rendered = renderTemplate($scope.state.templateContent, $scope.formValues.Variables, $scope.state.selectedTemplate.Variables);
|
||||
onChangeFormValues({ StackFileContent: rendered });
|
||||
}
|
||||
|
||||
async function initView() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
$scope.state.StackType = 2;
|
||||
@@ -328,8 +354,11 @@ angular
|
||||
|
||||
initView();
|
||||
|
||||
function onChangeFormValues(newValues) {
|
||||
$scope.formValues = newValues;
|
||||
function onChangeFormValues(values) {
|
||||
$scope.formValues = {
|
||||
...$scope.formValues,
|
||||
...values,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -131,13 +131,21 @@
|
||||
path-placeholder="docker-compose.yml"
|
||||
></git-form>
|
||||
|
||||
<custom-template-selector
|
||||
ng-show="state.Method === 'template'"
|
||||
new-template-path="docker.templates.custom.new"
|
||||
stack-type="state.StackType"
|
||||
on-change="(onChangeTemplateId)"
|
||||
value="state.selectedTemplateId"
|
||||
></custom-template-selector>
|
||||
<div ng-show="state.Method === 'template'">
|
||||
<custom-template-selector
|
||||
new-template-path="docker.templates.custom.new"
|
||||
stack-type="state.StackType"
|
||||
on-change="(onChangeTemplateId)"
|
||||
value="state.selectedTemplateId"
|
||||
></custom-template-selector>
|
||||
|
||||
<custom-templates-variables-field
|
||||
ng-if="isTemplateVariablesEnabled && state.selectedTemplate"
|
||||
definitions="state.selectedTemplate.Variables"
|
||||
value="formValues.Variables"
|
||||
on-change="(onChangeTemplateVariables)"
|
||||
></custom-templates-variables-field>
|
||||
</div>
|
||||
|
||||
<web-editor-form
|
||||
ng-if="state.Method === 'editor' || (state.Method === 'template' && state.selectedTemplateId)"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>
|
||||
{{ team.Name }}
|
||||
<span ng-if="!settings.TeamSync">{{ team.Name }}</span>
|
||||
<button class="btn btn-xs btn-danger" ng-if="isAdmin" ng-click="deleteTeam()"
|
||||
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this team</button
|
||||
>
|
||||
@@ -23,7 +23,9 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Leaders</td>
|
||||
<td>{{ leaderCount }}</td>
|
||||
<td>
|
||||
<span ng-if="!settings.TeamSync">{{ leaderCount }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Total users in team</td>
|
||||
@@ -175,11 +177,11 @@
|
||||
<i ng-if="user.TeamRole === 'Leader'" class="fa fa-user-plus" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
<i ng-if="user.TeamRole === 'Member'" class="fa fa-user" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
{{ user.TeamRole }}
|
||||
<span style="margin-left: 5px" ng-if="isAdmin">
|
||||
<span style="margin-left: 5px">
|
||||
<a style="margin-left: 5px" ng-click="promoteToLeader(user)" ng-if="user.TeamRole === 'Member'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
|
||||
><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Leader</a
|
||||
>
|
||||
<a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="user.TeamRole === 'Leader'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
|
||||
<a style="margin-left: 5px" ng-click="demoteToMember(user)" ng-if="isAdmin && user.TeamRole === 'Leader'" ng-class="{ 'btn disabled py-0': settings.TeamSync }"
|
||||
><i class="fa fa-user-times space-right" aria-hidden="true"></i>Member</a
|
||||
>
|
||||
</span>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !confirm-password-input -->
|
||||
<password-check-hint ng-if="!form.new_password.$valid"></password-check-hint>
|
||||
<password-check-hint password-valid="form.new_password.$valid && formValues.newPassword"></password-check-hint>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-2">
|
||||
<button
|
||||
|
||||
@@ -80,10 +80,10 @@
|
||||
<!-- !confirm-password-input -->
|
||||
|
||||
<!-- password-check-hint -->
|
||||
<div class="form-group" ng-if="AuthenticationMethod === 1 && !form.password.$valid">
|
||||
<div class="form-group" ng-if="AuthenticationMethod === 1">
|
||||
<div class="col-sm-3 col-lg-2"></div>
|
||||
<div class="col-sm-8">
|
||||
<password-check-hint></password-check-hint>
|
||||
<password-check-hint password-valid="form.password.$valid && formValues.Password"></password-check-hint>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ! password-check-hint -->
|
||||
@@ -140,7 +140,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || !formValues.Password || !formValues.ConfirmPassword || (AuthenticationMethod === 1 && form.$invalid) || (AuthenticationMethod === 1 && form.password.$viewValue !== formValues.ConfirmPassword)"
|
||||
ng-disabled="state.actionInProgress || !state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && (!formValues.Password || form.$invalid || formValues.Password !== formValues.ConfirmPassword))"
|
||||
ng-click="addUser()"
|
||||
button-spinner="state.actionInProgress"
|
||||
data-cy="user-createUserButton"
|
||||
|
||||
@@ -2,6 +2,7 @@ import _ from 'lodash';
|
||||
|
||||
import { Team } from '@/portainer/teams/types';
|
||||
import { Role, User, UserId } from '@/portainer/users/types';
|
||||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
export function createMockUsers(
|
||||
count: number,
|
||||
@@ -59,3 +60,25 @@ export function createMockResourceGroups(subscription: string, count: number) {
|
||||
|
||||
return { value: resourceGroups };
|
||||
}
|
||||
|
||||
export function createMockEnvironment(): Environment {
|
||||
return {
|
||||
TagIds: [],
|
||||
GroupId: 1,
|
||||
Type: 1,
|
||||
Name: 'environment',
|
||||
Status: 1,
|
||||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: { Snapshots: [] },
|
||||
EdgeKey: '',
|
||||
Id: 3,
|
||||
UserTrusted: false,
|
||||
Edge: {
|
||||
AsyncMode: false,
|
||||
PingInterval: 0,
|
||||
CommandInterval: 0,
|
||||
SnapshotInterval: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
5
app/react/docker/types.ts
Normal file
5
app/react/docker/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ResourceControlResponse } from '@/portainer/access-control/types';
|
||||
|
||||
export interface PortainerMetadata {
|
||||
ResourceControl: ResourceControlResponse;
|
||||
}
|
||||
@@ -38,12 +38,15 @@ export function intersectVariables(
|
||||
) {
|
||||
const oldVariablesWithLabel = oldVariables.filter((v) => !!v.label);
|
||||
|
||||
return [
|
||||
...oldVariablesWithLabel,
|
||||
...newVariables.filter(
|
||||
(v) => !oldVariablesWithLabel.find(({ name }) => name === v.name)
|
||||
),
|
||||
];
|
||||
return _.uniqBy(
|
||||
[
|
||||
...oldVariablesWithLabel,
|
||||
...newVariables.filter(
|
||||
(v) => !oldVariablesWithLabel.find(({ name }) => name === v.name)
|
||||
),
|
||||
],
|
||||
'name'
|
||||
);
|
||||
}
|
||||
|
||||
export function renderTemplate(
|
||||
@@ -68,5 +71,5 @@ export function renderTemplate(
|
||||
)
|
||||
);
|
||||
|
||||
return Mustache.render(template, state);
|
||||
return Mustache.render(template, state, undefined, { escape: (t) => t });
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function EnvironmentSelector({ value, onChange }: Props) {
|
||||
{environmentTypes.map((eType) => (
|
||||
<Option
|
||||
key={eType.id}
|
||||
featureId={eType.featureId}
|
||||
title={eType.title}
|
||||
description={eType.description}
|
||||
icon={eType.icon}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.selected .mask-icon {
|
||||
color: var(--selected-item-color);
|
||||
}
|
||||
|
||||
.mask-icon {
|
||||
color: var(--bg-boxselector-color);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './KaaSIcon.module.css';
|
||||
|
||||
export interface Props {
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KaaSIcon({ selected, className }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={clsx('fa-stack fa-1x', styles.root, className, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
<i className="fas fa-cloud fa-stack-2x" />
|
||||
<i className={clsx('fas fa-dharmachakra fa-stack-1x', styles.maskIcon)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
export const environmentTypes = [
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { KaaSIcon, Props as KaaSIconProps } from './KaaSIcon';
|
||||
|
||||
interface WizardEnvironmentOption {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string | { ({ selected, className }: KaaSIconProps): JSX.Element };
|
||||
description: string;
|
||||
featureId?: FeatureId;
|
||||
}
|
||||
|
||||
export const environmentTypes: WizardEnvironmentOption[] = [
|
||||
{
|
||||
id: 'docker',
|
||||
title: 'Docker',
|
||||
@@ -18,4 +30,18 @@ export const environmentTypes = [
|
||||
description: 'Connect to ACI environment via API',
|
||||
icon: 'fab fa-microsoft',
|
||||
},
|
||||
] as const;
|
||||
{
|
||||
id: 'nomad',
|
||||
title: 'Nomad',
|
||||
description: 'Connect to HashiCorp Nomad environment via API',
|
||||
icon: 'nomad-icon',
|
||||
featureId: FeatureId.NOMAD,
|
||||
},
|
||||
{
|
||||
id: 'kaas',
|
||||
title: 'KaaS',
|
||||
description: 'Provision a Kubernetes environment with a cloud provider',
|
||||
icon: KaaSIcon,
|
||||
featureId: FeatureId.KAAS_PROVISIONING,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -118,8 +118,8 @@ export function EnvironmentCreationView() {
|
||||
])
|
||||
),
|
||||
});
|
||||
if (localStorage.getItem('wizardRefferer') === 'environments') {
|
||||
localStorage.removeItem('wizardRefferer');
|
||||
if (localStorage.getItem('wizardReferrer') === 'environments') {
|
||||
localStorage.removeItem('wizardReferrer');
|
||||
router.stateService.go('portainer.endpoints');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import { useFormikContext } from 'formik';
|
||||
import { FileUploadField } from '@/portainer/components/form-components/FileUpload';
|
||||
import { SwitchField } from '@/portainer/components/form-components/SwitchField';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import {
|
||||
file,
|
||||
withFileSize,
|
||||
withFileType,
|
||||
} from '@/portainer/helpers/yup-file-validation';
|
||||
import { file, withFileSize } from '@/portainer/helpers/yup-file-validation';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
@@ -82,16 +78,9 @@ export function TLSFieldset() {
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5_242_880; // 5MB
|
||||
const ALLOWED_FILE_TYPES = [
|
||||
'application/x-x509-ca-cert',
|
||||
'application/x-pem-file',
|
||||
];
|
||||
|
||||
function certValidation() {
|
||||
return withFileType(
|
||||
withFileSize(file(), MAX_FILE_SIZE),
|
||||
ALLOWED_FILE_TYPES
|
||||
).when(['tls', 'skipVerify'], {
|
||||
return withFileSize(file(), MAX_FILE_SIZE).when(['tls', 'skipVerify'], {
|
||||
is: (tls: boolean, skipVerify: boolean) => tls && !skipVerify,
|
||||
then: (schema) => schema.required('File is required'),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useState } from 'react';
|
||||
import { CopyButton } from '@/portainer/components/Button/CopyButton';
|
||||
import { Code } from '@/portainer/components/Code';
|
||||
import { NavTabs } from '@/portainer/components/NavTabs/NavTabs';
|
||||
import { getAgentShortVersion } from '@/portainer/views/endpoints/helpers';
|
||||
import { useAgentDetails } from '@/portainer/environments/queries/useAgentDetails';
|
||||
|
||||
const deployments = [
|
||||
@@ -28,10 +27,10 @@ export function DeploymentScripts() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { agentVersion } = agentDetailsQuery;
|
||||
const { agentVersion, agentSecret } = agentDetailsQuery;
|
||||
|
||||
const options = deployments.map((c) => {
|
||||
const code = c.command(agentVersion);
|
||||
const code = c.command(agentVersion, agentSecret);
|
||||
|
||||
return {
|
||||
id: c.id,
|
||||
@@ -65,14 +64,41 @@ function DeployCode({ code }: DeployCodeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function linuxCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
function linuxCommand(agentVersion: string, agentSecret: string) {
|
||||
const secret =
|
||||
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent`;
|
||||
return `docker network create \\
|
||||
--driver overlay \\
|
||||
portainer_agent_network
|
||||
|
||||
docker service create \\
|
||||
--name portainer_agent \\
|
||||
--network portainer_agent_network \\
|
||||
-p 9001:9001/tcp ${secret}\\
|
||||
--mode global \\
|
||||
--constraint 'node.platform.os == linux' \\
|
||||
--mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock \\
|
||||
--mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volumes \\
|
||||
portainer/agent:${agentVersion}
|
||||
`;
|
||||
}
|
||||
|
||||
function winCommand(agentVersion: string) {
|
||||
const agentShortVersion = getAgentShortVersion(agentVersion);
|
||||
function winCommand(agentVersion: string, agentSecret: string) {
|
||||
const secret =
|
||||
agentSecret === '' ? '' : `\\\n -e AGENT_SECRET=${agentSecret} `;
|
||||
|
||||
return `curl -L https://downloads.portainer.io/ee${agentShortVersion}/agent-stack-windows.yml -o agent-stack-windows.yml && docker stack deploy --compose-file=agent-stack-windows.yml portainer-agent `;
|
||||
return `docker network create \\
|
||||
--driver overlay \\
|
||||
portainer_agent_network && \\
|
||||
docker service create \\
|
||||
--name portainer_agent \\
|
||||
--network portainer_agent_network \\
|
||||
-p 9001:9001/tcp ${secret}\\
|
||||
--mode global \\
|
||||
--constraint 'node.platform.os == windows' \\
|
||||
--mount type=npipe,src=\\\\.\\pipe\\docker_engine,dst=\\\\.\\pipe\\docker_engine \\
|
||||
--mount type=bind,src=C:\\ProgramData\\docker\\volumes,dst=C:\\ProgramData\\docker\\volumes \\
|
||||
portainer/agent:${agentVersion}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetTitle icon="fa-plug" title="Connected Environments" />
|
||||
<WidgetTitle icon="fa-plug" title="New Environments" />
|
||||
<WidgetBody>
|
||||
{environments.map((environment) => (
|
||||
<div className={styles.wizardListWrapper} key={environment.Id}>
|
||||
@@ -65,12 +65,7 @@ export function WizardEndpointsList({ environmentIds }: Props) {
|
||||
</div>
|
||||
{isEdgeEnvironment(environment.Type) && (
|
||||
<div className={styles.wizardListEdgeStatus}>
|
||||
<EdgeIndicator
|
||||
edgeId={environment.EdgeID}
|
||||
checkInInterval={environment.EdgeCheckinInterval}
|
||||
queryDate={environment.QueryDate}
|
||||
lastCheckInDate={environment.LastCheckInDate}
|
||||
/>
|
||||
<EdgeIndicator environment={environment} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { Input } from '@/portainer/components/form-components/Input';
|
||||
import { Button } from '@/portainer/components/Button';
|
||||
|
||||
const initialValues = {
|
||||
kubeConfig: '',
|
||||
name: '',
|
||||
meta: {
|
||||
groupId: 1,
|
||||
tagIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
export function KubeConfigTeaserForm() {
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={() => {}} validateOnMount>
|
||||
{() => (
|
||||
<Form className="mt-5">
|
||||
<FormSectionTitle>Environment details</FormSectionTitle>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<span className="text-primary">
|
||||
<i
|
||||
className="fa fa-exclamation-circle space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted small">
|
||||
Import the
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/"
|
||||
target="_blank"
|
||||
className="space-right space-left"
|
||||
rel="noreferrer"
|
||||
>
|
||||
kubeconfig file
|
||||
</a>
|
||||
of an existing Kubernetes cluster located on-premise or on a
|
||||
cloud platform. This will create a corresponding environment in
|
||||
Portainer and install the agent on the cluster. Please ensure:
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-sm-12 text-muted small">
|
||||
<ul className="p-2 pl-4">
|
||||
<li>You have a load balancer enabled in your cluster</li>
|
||||
<li>You specify current-context in your kubeconfig</li>
|
||||
<li>
|
||||
The kubeconfig is self-contained - including any required
|
||||
credentials.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Note: Officially supported cloud providers are Civo, Linode,
|
||||
DigitalOcean and Microsoft Azure (others are not guaranteed to
|
||||
work at present)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl label="Name" required>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
data-cy="endpointCreate-nameInput"
|
||||
placeholder="e.g. docker-prod01 / kubernetes-cluster01"
|
||||
readOnly
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Kubeconfig file"
|
||||
required
|
||||
inputId="kubeconfig_file"
|
||||
>
|
||||
<Button disabled>Select a file</Button>
|
||||
</FormControl>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
className="wizard-connect-button"
|
||||
loadingText="Connecting environment..."
|
||||
isLoading={false}
|
||||
disabled
|
||||
>
|
||||
<i className="fa fa-plug" aria-hidden="true" /> Connect
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
} from '@/portainer/environments/types';
|
||||
import { BoxSelectorOption } from '@/portainer/components/BoxSelector/types';
|
||||
import { commandsTabs } from '@/react/edge/components/EdgeScriptForm/scripts';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
|
||||
|
||||
import { AnalyticsStateKey } from '../types';
|
||||
import { EdgeAgentTab } from '../shared/EdgeAgentTab';
|
||||
|
||||
import { AgentPanel } from './AgentPanel';
|
||||
import { KubeConfigTeaserForm } from './KubeConfigTeaserForm';
|
||||
|
||||
interface Props {
|
||||
onCreate(environment: Environment, analytics: AnalyticsStateKey): void;
|
||||
@@ -20,6 +23,7 @@ interface Props {
|
||||
const options: BoxSelectorOption<
|
||||
| EnvironmentCreationTypes.AgentEnvironment
|
||||
| EnvironmentCreationTypes.EdgeAgentEnvironment
|
||||
| EnvironmentCreationTypes.KubeConfigEnvironment
|
||||
>[] = [
|
||||
{
|
||||
id: 'agent_endpoint',
|
||||
@@ -35,6 +39,14 @@ const options: BoxSelectorOption<
|
||||
description: '',
|
||||
value: EnvironmentCreationTypes.EdgeAgentEnvironment,
|
||||
},
|
||||
{
|
||||
id: 'kubeconfig_endpoint',
|
||||
icon: 'fas fa-cloud-upload-alt',
|
||||
label: 'Import',
|
||||
value: EnvironmentCreationTypes.KubeConfigEnvironment,
|
||||
description: 'Import an existing Kubernetes config',
|
||||
feature: FeatureId.K8S_CREATE_FROM_KUBECONFIG,
|
||||
},
|
||||
];
|
||||
|
||||
export function WizardKubernetes({ onCreate }: Props) {
|
||||
@@ -72,6 +84,15 @@ export function WizardKubernetes({ onCreate }: Props) {
|
||||
commands={[{ ...commandsTabs.k8sLinux, label: 'Linux' }]}
|
||||
/>
|
||||
);
|
||||
case EnvironmentCreationTypes.KubeConfigEnvironment:
|
||||
return (
|
||||
<div className="px-1 py-5 border border-solid border-orange-1">
|
||||
<BEFeatureIndicator
|
||||
featureId={options.find((o) => o.value === type)?.feature}
|
||||
/>
|
||||
<KubeConfigTeaserForm />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
throw new Error('Creation type not supported');
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
.root {
|
||||
--selected-item-color: var(--blue-2);
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 300px;
|
||||
border: 1px solid rgb(163, 163, 163);
|
||||
.optionTile {
|
||||
border-radius: 5px;
|
||||
padding: 25px 20px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 60%);
|
||||
margin: 0;
|
||||
display: block;
|
||||
width: 200px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
.feature {
|
||||
--selected-item-color: var(--blue-2);
|
||||
border: 1px solid rgb(163, 163, 163);
|
||||
}
|
||||
|
||||
.feature:hover {
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
|
||||
border: 1px solid var(--blue-2);
|
||||
color: #337ab7;
|
||||
}
|
||||
|
||||
.teaser {
|
||||
border: 2px solid var(--BE-only) !important;
|
||||
color: var(--text-muted-color);
|
||||
}
|
||||
|
||||
.teaser:hover {
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 80%);
|
||||
}
|
||||
|
||||
.active:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import clsx from 'clsx';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { BEFeatureIndicator } from '@/portainer/components/BEFeatureIndicator';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { isLimitedToBE } from '@/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import styles from './Option.module.css';
|
||||
|
||||
export interface SelectorItemType {
|
||||
@@ -12,6 +16,7 @@ export interface SelectorItemType {
|
||||
interface Props extends SelectorItemType {
|
||||
active?: boolean;
|
||||
onClick?(): void;
|
||||
featureId?: FeatureId;
|
||||
}
|
||||
|
||||
export function Option({
|
||||
@@ -20,13 +25,22 @@ export function Option({
|
||||
description,
|
||||
title,
|
||||
onClick = () => {},
|
||||
featureId,
|
||||
}: Props) {
|
||||
const Icon = typeof icon !== 'string' ? icon : null;
|
||||
|
||||
const isLimited = isLimitedToBE(featureId);
|
||||
return (
|
||||
<button
|
||||
className={clsx('border-0', styles.root, { [styles.active]: active })}
|
||||
className={clsx(
|
||||
styles.optionTile,
|
||||
isLimited ? styles.teaser : styles.feature,
|
||||
'border-0',
|
||||
{
|
||||
[styles.active]: active,
|
||||
}
|
||||
)}
|
||||
type="button"
|
||||
disabled={isLimited}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-center mt-2">
|
||||
@@ -37,9 +51,16 @@ export function Option({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<div className="mt-3 text-center flex flex-col">
|
||||
<h3>{title}</h3>
|
||||
<h5>{description}</h5>
|
||||
{isLimited && (
|
||||
<BEFeatureIndicator
|
||||
showIcon={false}
|
||||
featureId={featureId}
|
||||
className="!whitespace-normal"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ ARCH=$2
|
||||
DOCKER_VERSION=${3:1}
|
||||
DOWNLOAD_FOLDER=".tmp/download"
|
||||
|
||||
if [[ ${PLATFORM} == "darwin" ]]; then
|
||||
PLATFORM="mac"
|
||||
fi
|
||||
|
||||
if [[ ${ARCH} == "amd64" ]]; then
|
||||
ARCH="x86_64"
|
||||
@@ -18,6 +21,10 @@ elif [[ ${ARCH} == "arm" ]]; then
|
||||
ARCH="armhf"
|
||||
elif [[ ${ARCH} == "arm64" ]]; then
|
||||
ARCH="aarch64"
|
||||
elif [[ ${ARCH} == "ppc64le" ]]; then
|
||||
DOCKER_VERSION="18.06.3-ce"
|
||||
elif [[ ${ARCH} == "s390x" ]]; then
|
||||
DOCKER_VERSION="18.06.3-ce"
|
||||
fi
|
||||
|
||||
rm -rf "${DOWNLOAD_FOLDER}"
|
||||
|
||||
@@ -21,10 +21,10 @@ fi
|
||||
|
||||
|
||||
if [[ "$PLATFORM" == "windows" ]]; then
|
||||
wget -O "dist/docker-compose.plugin.exe" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-windows-${ARCH}.exe"
|
||||
chmod +x "dist/docker-compose.plugin.exe"
|
||||
wget -O "dist/docker-compose.exe" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-windows-${ARCH}.exe"
|
||||
chmod +x "dist/docker-compose.exe"
|
||||
else
|
||||
wget -O "dist/docker-compose.plugin" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-${PLATFORM}-${ARCH}"
|
||||
chmod +x "dist/docker-compose.plugin"
|
||||
wget -O "dist/docker-compose" "https://github.com/docker/compose/releases/download/$COMPOSE_VERSION/docker-compose-${PLATFORM}-${ARCH}"
|
||||
chmod +x "dist/docker-compose"
|
||||
fi
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.14.0",
|
||||
"version": "2.14.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
Reference in New Issue
Block a user