Compare commits
69 Commits
feat/ee-83
...
demo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3886fd25 | ||
|
|
f347d97daf | ||
|
|
d5cee5b8b1 | ||
|
|
4da6824bc7 | ||
|
|
80b6b6e300 | ||
|
|
484dab5932 | ||
|
|
f8bd075ce4 | ||
|
|
cd58c16b4e | ||
|
|
5ebb03cb4e | ||
|
|
dffcd3fdfd | ||
|
|
3f7687e78a | ||
|
|
0f58ece899 | ||
|
|
b0ad212858 | ||
|
|
7eb2fd3424 | ||
|
|
4c0d8ce732 | ||
|
|
e1cc4bc9a1 | ||
|
|
f2682d82b6 | ||
|
|
d3576fe8e6 | ||
|
|
f34d27df0f | ||
|
|
f228b28639 | ||
|
|
d7b4e4aba1 | ||
|
|
7234c443e8 | ||
|
|
5b844fd40a | ||
|
|
7f2ef8fb06 | ||
|
|
9f817749c1 | ||
|
|
36198b57a5 | ||
|
|
70f9e37eab | ||
|
|
24f69f0185 | ||
|
|
4a2e9a892d | ||
|
|
1e4bedde4b | ||
|
|
9db07e7f4e | ||
|
|
d4b8d9947d | ||
|
|
9194ddcd03 | ||
|
|
e1edf37770 | ||
|
|
82b73c06b4 | ||
|
|
ae286998ab | ||
|
|
ffc3ed67e2 | ||
|
|
6252be4a08 | ||
|
|
75481d928e | ||
|
|
d65d65803f | ||
|
|
6a8fc253bd | ||
|
|
1fa8921c11 | ||
|
|
ba3892aebf | ||
|
|
27fc700d6f | ||
|
|
3187cb0ada | ||
|
|
b462a15921 | ||
|
|
11a646a076 | ||
|
|
4c1edaf251 | ||
|
|
b45c4f8bea | ||
|
|
968f070b0b | ||
|
|
e679483ffd | ||
|
|
494ef6c392 | ||
|
|
7951c1d150 | ||
|
|
0083bdb6a2 | ||
|
|
e65881223d | ||
|
|
f0c9058568 | ||
|
|
afd654e4a8 | ||
|
|
0ef116d6b5 | ||
|
|
ee64a53782 | ||
|
|
4a0c899df5 | ||
|
|
840603bb8c | ||
|
|
5fe79621a6 | ||
|
|
d3b9822105 | ||
|
|
9163bcae25 | ||
|
|
17dad6e36f | ||
|
|
75b836453a | ||
|
|
b8866d487f | ||
|
|
dfb3b3aaa1 | ||
|
|
09fdc9781c |
@@ -6,10 +6,10 @@ Some basic conventions for contributing to this project.
|
||||
|
||||
Please make sure that there aren't existing pull requests attempting to address the issue mentioned. Likewise, please check for issues related to update, as someone else may be working on the issue in a branch or fork.
|
||||
|
||||
* Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring
|
||||
* Develop in a topic branch, not master/develop
|
||||
- Please open a discussion in a new issue / existing issue to talk about the changes you'd like to bring
|
||||
- Develop in a topic branch, not master/develop
|
||||
|
||||
When creating a new branch, prefix it with the *type* of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator).
|
||||
When creating a new branch, prefix it with the _type_ of the change (see section **Commit Message Format** below), the associated opened issue number, a dash and some text describing the issue (using dash as a separator).
|
||||
|
||||
For example, if you work on a bugfix for the issue #361, you could name the branch `fix361-template-selection`.
|
||||
|
||||
@@ -37,14 +37,14 @@ Lines should not exceed 100 characters. This allows the message to be easier to
|
||||
|
||||
Must be one of the following:
|
||||
|
||||
* **feat**: A new feature
|
||||
* **fix**: A bug fix
|
||||
* **docs**: Documentation only changes
|
||||
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing
|
||||
semi-colons, etc)
|
||||
* **refactor**: A code change that neither fixes a bug or adds a feature
|
||||
* **test**: Adding missing tests
|
||||
* **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
|
||||
- **refactor**: A code change that neither fixes a bug or adds a feature
|
||||
- **test**: Adding missing tests
|
||||
- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation
|
||||
generation
|
||||
|
||||
### Scope
|
||||
@@ -57,9 +57,9 @@ You can use the **area** label tag associated on the issue here (for `area/conta
|
||||
|
||||
The subject contains succinct description of the change:
|
||||
|
||||
* use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
* don't capitalize first letter
|
||||
* no dot (.) at the end
|
||||
- use the imperative, present tense: "change" not "changed" nor "changes"
|
||||
- don't capitalize first letter
|
||||
- no dot (.) at the end
|
||||
|
||||
## Contribution process
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package migrator
|
||||
|
||||
import "github.com/portainer/portainer/api"
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func (m *Migrator) updateTagsToDBVersion23() error {
|
||||
tags, err := m.tagService.Tags()
|
||||
|
||||
14
api/bolt/migrator/migrate_dbversion23.go
Normal file
14
api/bolt/migrator/migrate_dbversion23.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) updateSettingsToDBVersion24() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
legacySettings.AllowDeviceMappingForRegularUsers = true
|
||||
legacySettings.AllowStackManagementForRegularUsers = true
|
||||
legacySettings.AllowHostNamespaceForRegularUsers = true
|
||||
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
@@ -322,5 +322,13 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.24.1
|
||||
if m.currentDBVersion < 24 {
|
||||
err := m.updateSettingsToDBVersion24()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/cron"
|
||||
@@ -70,6 +70,45 @@ func initStore(dataStorePath string, fileService portainer.FileService) *bolt.St
|
||||
return store
|
||||
}
|
||||
|
||||
func initDemoData(store *bolt.Store, cryptoService portainer.CryptoService) error {
|
||||
password, err := cryptoService.Hash("tryportainer")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
admin := &portainer.User{
|
||||
Username: "admin",
|
||||
Password: password,
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
|
||||
err = store.UserService.CreateUser(admin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localEndpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(1),
|
||||
Name: "local",
|
||||
URL: "unix:///var/run/docker.sock",
|
||||
PublicURL: "demo.portainer.io",
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
err = store.EndpointService.CreateEndpoint(localEndpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initComposeStackManager(dataStorePath string, reverseTunnelService portainer.ReverseTunnelService) portainer.ComposeStackManager {
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
}
|
||||
@@ -269,13 +308,16 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||
portainer.LDAPGroupSearchSettings{},
|
||||
},
|
||||
},
|
||||
OAuthSettings: portainer.OAuthSettings{},
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
SnapshotInterval: *flags.SnapshotInterval,
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
OAuthSettings: portainer.OAuthSettings{},
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
AllowDeviceMappingForRegularUsers: true,
|
||||
AllowStackManagementForRegularUsers: true,
|
||||
EnableHostManagementFeatures: false,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
SnapshotInterval: *flags.SnapshotInterval,
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
}
|
||||
|
||||
if *flags.Templates != "" {
|
||||
@@ -529,6 +571,8 @@ func main() {
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
initDemoData(store, cryptoService)
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
err := initKeyPair(fileService, digitalSignatureService)
|
||||
|
||||
@@ -6,6 +6,24 @@ import (
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration creates a basic tls.Config to be used by servers with recommended TLS settings
|
||||
func CreateServerTLSConfiguration() *tls.Config {
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
tls.TLS_AES_256_GCM_SHA384,
|
||||
tls.TLS_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from memory.
|
||||
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
|
||||
@@ -89,6 +89,11 @@ const (
|
||||
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
|
||||
)
|
||||
|
||||
// Demo errors.
|
||||
const (
|
||||
ErrNotAvailableInDemo = Error("This feature is not available in the demo version of Portainer")
|
||||
)
|
||||
|
||||
// Extension errors.
|
||||
const (
|
||||
ErrExtensionAlreadyEnabled = Error("This extension is already enabled")
|
||||
|
||||
@@ -21,6 +21,10 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
if endpointID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", portainer.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
|
||||
@@ -41,16 +41,21 @@ func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err}
|
||||
}
|
||||
|
||||
for _, existingExtension := range extensions {
|
||||
if existingExtension.ID == extensionID && existingExtension.Enabled {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled}
|
||||
}
|
||||
}
|
||||
|
||||
extension := &portainer.Extension{
|
||||
ID: extensionID,
|
||||
}
|
||||
|
||||
for _, existingExtension := range extensions {
|
||||
if existingExtension.ID == extensionID && (existingExtension.Enabled || !existingExtension.License.Valid) {
|
||||
if existingExtension.License.LicenseKey == payload.License {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled}
|
||||
}
|
||||
|
||||
_ = handler.ExtensionManager.DisableExtension(&existingExtension)
|
||||
extension.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
|
||||
@@ -68,15 +73,14 @@ func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err}
|
||||
}
|
||||
|
||||
extension.Enabled = true
|
||||
|
||||
if extension.ID == portainer.RBACExtension {
|
||||
if extension.ID == portainer.RBACExtension && !extension.Enabled {
|
||||
err = handler.upgradeRBACData()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err}
|
||||
}
|
||||
}
|
||||
|
||||
extension.Enabled = true
|
||||
err = handler.ExtensionService.Persist(extension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
|
||||
|
||||
@@ -46,10 +46,21 @@ func (handler *Handler) extensionUpload(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||
|
||||
extensions, err := handler.ExtensionService.Extensions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err}
|
||||
}
|
||||
|
||||
extension := &portainer.Extension{
|
||||
ID: extensionID,
|
||||
}
|
||||
|
||||
for _, existingExtension := range extensions {
|
||||
if existingExtension.ID == extensionID && (existingExtension.Enabled || !existingExtension.License.Valid) {
|
||||
extension.Enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
_ = handler.ExtensionManager.DisableExtension(extension)
|
||||
|
||||
err = handler.ExtensionManager.InstallExtension(extension, payload.License, payload.ArchiveFileName, payload.ExtensionArchive)
|
||||
@@ -57,15 +68,15 @@ func (handler *Handler) extensionUpload(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to install extension", err}
|
||||
}
|
||||
|
||||
extension.Enabled = true
|
||||
|
||||
if extension.ID == portainer.RBACExtension {
|
||||
if extension.ID == portainer.RBACExtension && !extension.Enabled {
|
||||
err = handler.upgradeRBACData()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", err}
|
||||
}
|
||||
}
|
||||
|
||||
extension.Enabled = true
|
||||
|
||||
err = handler.ExtensionService.Persist(extension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
|
||||
|
||||
@@ -6,19 +6,22 @@ import (
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type publicSettingsResponse struct {
|
||||
LogoURL string `json:"LogoURL"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
|
||||
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
|
||||
ExternalTemplates bool `json:"ExternalTemplates"`
|
||||
OAuthLoginURI string `json:"OAuthLoginURI"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
|
||||
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
|
||||
ExternalTemplates bool `json:"ExternalTemplates"`
|
||||
OAuthLoginURI string `json:"OAuthLoginURI"`
|
||||
AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"`
|
||||
AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"`
|
||||
AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"`
|
||||
}
|
||||
|
||||
// GET request on /api/settings/public
|
||||
@@ -29,19 +32,22 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: settings.LogoURL,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||
AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers,
|
||||
EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
|
||||
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
||||
ExternalTemplates: false,
|
||||
LogoURL: settings.LogoURL,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||
AllowVolumeBrowserForRegularUsers: settings.AllowVolumeBrowserForRegularUsers,
|
||||
EnableHostManagementFeatures: settings.EnableHostManagementFeatures,
|
||||
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
||||
AllowHostNamespaceForRegularUsers: settings.AllowHostNamespaceForRegularUsers,
|
||||
AllowStackManagementForRegularUsers: settings.AllowStackManagementForRegularUsers,
|
||||
ExternalTemplates: false,
|
||||
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login",
|
||||
settings.OAuthSettings.AuthorizationURI,
|
||||
settings.OAuthSettings.ClientID,
|
||||
settings.OAuthSettings.RedirectURI,
|
||||
settings.OAuthSettings.Scopes),
|
||||
AllowDeviceMappingForRegularUsers: settings.AllowDeviceMappingForRegularUsers,
|
||||
}
|
||||
|
||||
if settings.TemplatesURL != "" {
|
||||
|
||||
@@ -7,24 +7,27 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
type settingsUpdatePayload struct {
|
||||
LogoURL *string
|
||||
BlackListedLabels []portainer.Pair
|
||||
AuthenticationMethod *int
|
||||
LDAPSettings *portainer.LDAPSettings
|
||||
OAuthSettings *portainer.OAuthSettings
|
||||
AllowBindMountsForRegularUsers *bool
|
||||
AllowPrivilegedModeForRegularUsers *bool
|
||||
AllowVolumeBrowserForRegularUsers *bool
|
||||
EnableHostManagementFeatures *bool
|
||||
SnapshotInterval *string
|
||||
TemplatesURL *string
|
||||
EdgeAgentCheckinInterval *int
|
||||
EnableEdgeComputeFeatures *bool
|
||||
LogoURL *string
|
||||
BlackListedLabels []portainer.Pair
|
||||
AuthenticationMethod *int
|
||||
LDAPSettings *portainer.LDAPSettings
|
||||
OAuthSettings *portainer.OAuthSettings
|
||||
AllowBindMountsForRegularUsers *bool
|
||||
AllowPrivilegedModeForRegularUsers *bool
|
||||
AllowVolumeBrowserForRegularUsers *bool
|
||||
EnableHostManagementFeatures *bool
|
||||
SnapshotInterval *string
|
||||
TemplatesURL *string
|
||||
EdgeAgentCheckinInterval *int
|
||||
EnableEdgeComputeFeatures *bool
|
||||
AllowStackManagementForRegularUsers *bool
|
||||
AllowHostNamespaceForRegularUsers *bool
|
||||
AllowDeviceMappingForRegularUsers *bool
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
@@ -114,6 +117,14 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
settings.EnableEdgeComputeFeatures = *payload.EnableEdgeComputeFeatures
|
||||
}
|
||||
|
||||
if payload.AllowStackManagementForRegularUsers != nil {
|
||||
settings.AllowStackManagementForRegularUsers = *payload.AllowStackManagementForRegularUsers
|
||||
}
|
||||
|
||||
if payload.AllowHostNamespaceForRegularUsers != nil {
|
||||
settings.AllowHostNamespaceForRegularUsers = *payload.AllowHostNamespaceForRegularUsers
|
||||
}
|
||||
|
||||
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
|
||||
err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval)
|
||||
if err != nil {
|
||||
@@ -125,6 +136,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
if payload.AllowDeviceMappingForRegularUsers != nil {
|
||||
settings.AllowDeviceMappingForRegularUsers = *payload.AllowDeviceMappingForRegularUsers
|
||||
}
|
||||
|
||||
tlsError := handler.updateTLS(settings)
|
||||
if tlsError != nil {
|
||||
return tlsError
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
@@ -283,6 +282,7 @@ type composeStackDeploymentConfig struct {
|
||||
dockerhub *portainer.DockerHub
|
||||
registries []portainer.Registry
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
}
|
||||
|
||||
func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) {
|
||||
@@ -302,12 +302,18 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
user, err := handler.UserService.User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
}
|
||||
|
||||
config := &composeStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
dockerhub: dockerhub,
|
||||
registries: filteredRegistries,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
}
|
||||
|
||||
return config, nil
|
||||
@@ -324,7 +330,16 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
if !settings.AllowBindMountsForRegularUsers && !config.isAdmin {
|
||||
isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if (!settings.AllowBindMountsForRegularUsers ||
|
||||
!settings.AllowPrivilegedModeForRegularUsers ||
|
||||
!settings.AllowHostNamespaceForRegularUsers ||
|
||||
!settings.AllowDeviceMappingForRegularUsers) && !isAdminOrEndpointAdmin {
|
||||
|
||||
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
|
||||
|
||||
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
|
||||
@@ -332,13 +347,10 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
|
||||
return err
|
||||
}
|
||||
|
||||
valid, err := handler.isValidStackFile(stackContent)
|
||||
err = handler.isValidStackFile(stackContent, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return errors.New("bind-mount disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
|
||||
handler.stackCreationMutex.Lock()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
@@ -10,7 +9,7 @@ import (
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
@@ -292,6 +291,7 @@ type swarmStackDeploymentConfig struct {
|
||||
registries []portainer.Registry
|
||||
prune bool
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) {
|
||||
@@ -311,6 +311,11 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
user, err := handler.UserService.User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
}
|
||||
|
||||
config := &swarmStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
@@ -318,6 +323,7 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
|
||||
registries: filteredRegistries,
|
||||
prune: prune,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
}
|
||||
|
||||
return config, nil
|
||||
@@ -329,7 +335,13 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
|
||||
return err
|
||||
}
|
||||
|
||||
if !settings.AllowBindMountsForRegularUsers && !config.isAdmin {
|
||||
isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin {
|
||||
|
||||
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
|
||||
|
||||
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
|
||||
@@ -337,13 +349,10 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
|
||||
return err
|
||||
}
|
||||
|
||||
valid, err := handler.isValidStackFile(stackContent)
|
||||
err = handler.isValidStackFile(stackContent, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return errors.New("bind-mount disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
|
||||
handler.stackCreationMutex.Lock()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -87,3 +88,54 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) {
|
||||
if securityContext.IsAdmin {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
_, err := handler.ExtensionService.Extension(portainer.RBACExtension)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return false, nil
|
||||
} else if err != nil && err != portainer.ErrObjectNotFound {
|
||||
return false, err
|
||||
}
|
||||
|
||||
user, err := handler.UserService.User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, ok := user.EndpointAuthorizations[endpointID][portainer.EndpointResourcesAccess]
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) {
|
||||
isAdmin := user.Role == portainer.AdministratorRole
|
||||
|
||||
rbacExtension, err := handler.ExtensionService.Extension(portainer.RBACExtension)
|
||||
if err != nil && err != portainer.ErrObjectNotFound {
|
||||
return false, errors.New("Unable to verify if RBAC extension is loaded")
|
||||
}
|
||||
|
||||
endpointResourceAccess := false
|
||||
_, ok := user.EndpointAuthorizations[portainer.EndpointID(endpointID)][portainer.EndpointResourcesAccess]
|
||||
if ok {
|
||||
endpointResourceAccess = true
|
||||
}
|
||||
|
||||
if rbacExtension != nil {
|
||||
if isAdmin || endpointResourceAccess {
|
||||
return true, nil
|
||||
}
|
||||
} else {
|
||||
if isAdmin {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -43,6 +43,29 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
}
|
||||
|
||||
if !settings.AllowStackManagementForRegularUsers {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
|
||||
}
|
||||
|
||||
canCreate, err := handler.userCanCreateStack(securityContext, portainer.EndpointID(endpointID))
|
||||
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack creation", err}
|
||||
}
|
||||
|
||||
if !canCreate {
|
||||
errMsg := "Stack creation is disabled for non-admin users"
|
||||
return &httperror.HandlerError{http.StatusForbidden, errMsg, errors.New(errMsg)}
|
||||
}
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
@@ -97,10 +120,10 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
|
||||
func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error) {
|
||||
func (handler *Handler) isValidStackFile(stackFileContent []byte, settings *portainer.Settings) error {
|
||||
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
composeConfigFile := types.ConfigFile{
|
||||
@@ -117,19 +140,33 @@ func (handler *Handler) isValidStackFile(stackFileContent []byte) (bool, error)
|
||||
options.SkipInterpolation = true
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
for key := range composeConfig.Services {
|
||||
service := composeConfig.Services[key]
|
||||
for _, volume := range service.Volumes {
|
||||
if volume.Type == "bind" {
|
||||
return false, nil
|
||||
if !settings.AllowBindMountsForRegularUsers {
|
||||
for _, volume := range service.Volumes {
|
||||
if volume.Type == "bind" {
|
||||
return errors.New("bind-mount disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !settings.AllowPrivilegedModeForRegularUsers && service.Privileged == true {
|
||||
return errors.New("privileged mode disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !settings.AllowHostNamespaceForRegularUsers && service.Pid == "host" {
|
||||
return errors.New("pid host disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !settings.AllowDeviceMappingForRegularUsers && service.Devices != nil && len(service.Devices) > 0 {
|
||||
return errors.New("device mapping disabled for non administrator users")
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError {
|
||||
|
||||
@@ -22,6 +22,10 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", portainer.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
if tokenData.ID == portainer.UserID(userID) {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", portainer.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
|
||||
@@ -33,6 +33,10 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid user identifier route variable", err}
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "This feature is not available in the demo version of Portainer", portainer.ErrNotAvailableInDemo}
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -147,3 +152,88 @@ func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelB
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
type PartialContainer struct {
|
||||
HostConfig struct {
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []interface{} `json:"Devices"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
forbiddenResponse := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := transport.userService.User(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rbacExtension, err := transport.extensionService.Extension(portainer.RBACExtension)
|
||||
if err != nil && err != portainer.ErrObjectNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpointResourceAccess := false
|
||||
_, ok := user.EndpointAuthorizations[portainer.EndpointID(transport.endpoint.ID)][portainer.EndpointResourcesAccess]
|
||||
if ok {
|
||||
endpointResourceAccess = true
|
||||
}
|
||||
|
||||
isAdmin := (rbacExtension != nil && endpointResourceAccess) || tokenData.Role == portainer.AdministratorRole
|
||||
|
||||
if !isAdmin {
|
||||
settings, err := transport.settingsService.Settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !settings.AllowPrivilegedModeForRegularUsers ||
|
||||
!settings.AllowHostNamespaceForRegularUsers ||
|
||||
!settings.AllowDeviceMappingForRegularUsers {
|
||||
|
||||
body, err := ioutil.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
partialContainer := &PartialContainer{}
|
||||
err = json.Unmarshal(body, partialContainer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if partialContainer.HostConfig.Privileged {
|
||||
return forbiddenResponse, errors.New("forbidden to use privileged mode")
|
||||
}
|
||||
|
||||
if partialContainer.HostConfig.PidMode == "host" {
|
||||
return forbiddenResponse, errors.New("forbidden to use pid host namespace")
|
||||
}
|
||||
|
||||
if len(partialContainer.HostConfig.Devices) > 0 {
|
||||
return nil, errors.New("forbidden to use device mapping")
|
||||
}
|
||||
|
||||
request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
}
|
||||
|
||||
response, err := transport.executeDockerRequest(request)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusCreated {
|
||||
err = transport.decorateGenericResourceCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res
|
||||
func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/containers/create":
|
||||
return transport.decorateGenericResourceCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
return transport.decorateContainerCreationOperation(request, containerObjectIdentifier, portainer.ContainerResourceControl)
|
||||
|
||||
case "/containers/prune":
|
||||
return transport.administratorOperation(request)
|
||||
@@ -656,6 +656,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
accessContext := ®istryAccessContext{
|
||||
isAdmin: true,
|
||||
userID: tokenData.ID,
|
||||
|
||||
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
"github.com/portainer/portainer/api/http/handler/edgetemplates"
|
||||
@@ -338,8 +339,14 @@ func (server *Server) Start() error {
|
||||
SchedulesHanlder: schedulesHandler,
|
||||
}
|
||||
|
||||
if server.SSL {
|
||||
return http.ListenAndServeTLS(server.BindAddress, server.SSLCert, server.SSLKey, server.Handler)
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: server.Handler,
|
||||
}
|
||||
return http.ListenAndServe(server.BindAddress, server.Handler)
|
||||
|
||||
if server.SSL {
|
||||
httpServer.TLSConfig = crypto.CreateServerTLSConfiguration()
|
||||
return httpServer.ListenAndServeTLS(server.SSLCert, server.SSLKey)
|
||||
}
|
||||
return httpServer.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -420,19 +420,22 @@ type (
|
||||
|
||||
// Settings represents the application settings
|
||||
Settings struct {
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
OAuthSettings OAuthSettings `json:"OAuthSettings"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
|
||||
SnapshotInterval string `json:"SnapshotInterval"`
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
|
||||
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
OAuthSettings OAuthSettings `json:"OAuthSettings"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
AllowVolumeBrowserForRegularUsers bool `json:"AllowVolumeBrowserForRegularUsers"`
|
||||
SnapshotInterval string `json:"SnapshotInterval"`
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
|
||||
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures"`
|
||||
AllowStackManagementForRegularUsers bool `json:"AllowStackManagementForRegularUsers"`
|
||||
AllowHostNamespaceForRegularUsers bool `json:"AllowHostNamespaceForRegularUsers"`
|
||||
AllowDeviceMappingForRegularUsers bool `json:"AllowDeviceMappingForRegularUsers"`
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
@@ -1007,9 +1010,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "1.24.0"
|
||||
APIVersion = "1.24.1"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 23
|
||||
DBVersion = 24
|
||||
// AssetsServerURL represents the URL of the Portainer asset server
|
||||
AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com"
|
||||
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
|
||||
|
||||
@@ -54,7 +54,7 @@ info:
|
||||
|
||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
|
||||
|
||||
version: "1.24.0"
|
||||
version: "1.24.1"
|
||||
title: "Portainer API"
|
||||
contact:
|
||||
email: "info@portainer.io"
|
||||
@@ -3174,7 +3174,7 @@ definitions:
|
||||
description: "Is analytics enabled"
|
||||
Version:
|
||||
type: "string"
|
||||
example: "1.24.0"
|
||||
example: "1.24.1"
|
||||
description: "Portainer API version"
|
||||
PublicSettingsInspectResponse:
|
||||
type: "object"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"packageName": "portainer",
|
||||
"packageVersion": "1.24.0",
|
||||
"packageVersion": "1.24.1",
|
||||
"projectName": "portainer"
|
||||
}
|
||||
|
||||
@@ -1,81 +1,82 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.docker')
|
||||
.controller('ContainerNetworksDatatableController', ['$scope', '$controller', 'DatatableService',
|
||||
function ($scope, $controller, DatatableService) {
|
||||
angular.module('portainer.docker').controller('ContainerNetworksDatatableController', [
|
||||
'$scope',
|
||||
'$controller',
|
||||
'DatatableService',
|
||||
function ($scope, $controller, DatatableService) {
|
||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||
this.state = Object.assign(this.state, {
|
||||
expandedItems: [],
|
||||
expandAll: true,
|
||||
});
|
||||
|
||||
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
|
||||
this.state = Object.assign(this.state, {
|
||||
expandedItems: [],
|
||||
expandAll: true
|
||||
});
|
||||
|
||||
this.expandItem = function (item, expanded) {
|
||||
if (!this.itemCanExpand(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.Expanded = expanded;
|
||||
if (!expanded) {
|
||||
item.Highlighted = false;
|
||||
}
|
||||
if (!item.Expanded) {
|
||||
this.state.expandAll = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.itemCanExpand = function (item) {
|
||||
return item.GlobalIPv6Address !== '';
|
||||
this.expandItem = function (item, expanded) {
|
||||
if (!this.itemCanExpand(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasExpandableItems = function () {
|
||||
return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length;
|
||||
};
|
||||
item.Expanded = expanded;
|
||||
if (!expanded) {
|
||||
item.Highlighted = false;
|
||||
}
|
||||
if (!item.Expanded) {
|
||||
this.state.expandAll = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.expandAll = function () {
|
||||
this.state.expandAll = !this.state.expandAll;
|
||||
_.forEach(this.dataset, (item) => {
|
||||
if (this.itemCanExpand(item)) {
|
||||
this.expandItem(item, this.state.expandAll);
|
||||
}
|
||||
});
|
||||
};
|
||||
this.itemCanExpand = function (item) {
|
||||
return item.GlobalIPv6Address !== '';
|
||||
};
|
||||
|
||||
this.$onInit = function () {
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
this.hasExpandableItems = function () {
|
||||
return _.filter(this.dataset, (item) => this.itemCanExpand(item)).length;
|
||||
};
|
||||
|
||||
this.state.orderBy = this.orderBy;
|
||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
||||
if (storedOrder !== null) {
|
||||
this.state.reverseOrder = storedOrder.reverse;
|
||||
this.state.orderBy = storedOrder.orderBy;
|
||||
this.expandAll = function () {
|
||||
this.state.expandAll = !this.state.expandAll;
|
||||
_.forEach(this.dataset, (item) => {
|
||||
if (this.itemCanExpand(item)) {
|
||||
this.expandItem(item, this.state.expandAll);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
this.onTextFilterChange();
|
||||
}
|
||||
this.$onInit = function () {
|
||||
this.setDefaults();
|
||||
this.prepareTableFromDataset();
|
||||
|
||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
||||
if (storedFilters !== null) {
|
||||
this.filters = storedFilters;
|
||||
}
|
||||
if (this.filters && this.filters.state) {
|
||||
this.filters.state.open = false;
|
||||
}
|
||||
this.state.orderBy = this.orderBy;
|
||||
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
|
||||
if (storedOrder !== null) {
|
||||
this.state.reverseOrder = storedOrder.reverse;
|
||||
this.state.orderBy = storedOrder.orderBy;
|
||||
}
|
||||
|
||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
||||
if (storedSettings !== null) {
|
||||
this.settings = storedSettings;
|
||||
this.settings.open = false;
|
||||
}
|
||||
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
|
||||
if (textFilter !== null) {
|
||||
this.state.textFilter = textFilter;
|
||||
this.onTextFilterChange();
|
||||
}
|
||||
|
||||
_.forEach(this.dataset, (item) => {
|
||||
item.Expanded = true;
|
||||
item.Highlighted = true;
|
||||
});
|
||||
};
|
||||
}
|
||||
]);
|
||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
||||
if (storedFilters !== null) {
|
||||
this.filters = storedFilters;
|
||||
}
|
||||
if (this.filters && this.filters.state) {
|
||||
this.filters.state.open = false;
|
||||
}
|
||||
|
||||
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
|
||||
if (storedSettings !== null) {
|
||||
this.settings = storedSettings;
|
||||
this.settings.open = false;
|
||||
}
|
||||
|
||||
_.forEach(this.dataset, (item) => {
|
||||
item.Expanded = true;
|
||||
item.Highlighted = true;
|
||||
});
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -120,11 +120,11 @@
|
||||
</div>
|
||||
<div class="menuContent">
|
||||
<div class="md-checkbox">
|
||||
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedImages" ng-change="$ctrl.onUsageFilterChange()" />
|
||||
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedImages" ng-change="$ctrl.onstateFilterChange()" />
|
||||
<label for="filter_usage_usedImages">Used images</label>
|
||||
</div>
|
||||
<div class="md-checkbox">
|
||||
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedImages" ng-change="$ctrl.onUsageFilterChange()" />
|
||||
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedImages" ng-change="$ctrl.onstateFilterChange()" />
|
||||
<label for="filter_usage_unusedImages">Unused images</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
</div>
|
||||
<div class="menuContent">
|
||||
<div class="md-checkbox">
|
||||
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedVolumes" ng-change="$ctrl.onUsageFilterChange()" />
|
||||
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedVolumes" ng-change="$ctrl.onstateFilterChange()" />
|
||||
<label for="filter_usage_usedImages">Used volumes</label>
|
||||
</div>
|
||||
<div class="md-checkbox">
|
||||
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedVolumes" ng-change="$ctrl.onUsageFilterChange()" />
|
||||
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedVolumes" ng-change="$ctrl.onstateFilterChange()" />
|
||||
<label for="filter_usage_unusedImages">Unused volumes</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,5 +6,6 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
|
||||
standaloneManagement: '<',
|
||||
adminAccess: '<',
|
||||
offlineMode: '<',
|
||||
showStacks: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<li class="sidebar-list" ng-if="!$ctrl.offlineMode" authorization="DockerContainerCreate, PortainerStackCreate">
|
||||
<a ui-sref="portainer.templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<li class="sidebar-list" ng-if="$ctrl.showStacks">
|
||||
<a ui-sref="portainer.stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="$ctrl.swarmManagement">
|
||||
|
||||
@@ -38,7 +38,14 @@
|
||||
<div class="form-group col-md-12">
|
||||
<label for="nfs_mountpoint" class="col-sm-2 col-md-1 control-label text-left">Mount point</label>
|
||||
<div class="col-sm-10 col-md-11">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.data.mountPoint" name="nfs_mountpoint" placeholder="e.g. /export/share, :/export/share, /share or :/share" required />
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.data.mountPoint"
|
||||
name="nfs_mountpoint"
|
||||
placeholder="e.g. /export/share, :/export/share, /share or :/share"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_mountpoint.$invalid">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ContainerDetailsViewModel, ContainerViewModel, ContainerStatsViewModel } from '../models/container';
|
||||
import { ContainerDetailsViewModel, ContainerStatsViewModel, ContainerViewModel } from '../models/container';
|
||||
|
||||
angular.module('portainer.docker').factory('ContainerService', [
|
||||
'$q',
|
||||
|
||||
@@ -30,6 +30,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
'SettingsService',
|
||||
'PluginService',
|
||||
'HttpRequestHelper',
|
||||
'ExtensionService',
|
||||
function (
|
||||
$q,
|
||||
$scope,
|
||||
@@ -55,7 +56,8 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
SystemService,
|
||||
SettingsService,
|
||||
PluginService,
|
||||
HttpRequestHelper
|
||||
HttpRequestHelper,
|
||||
ExtensionService
|
||||
) {
|
||||
$scope.create = create;
|
||||
|
||||
@@ -603,7 +605,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
async function initView() {
|
||||
var nodeName = $transition$.params().nodeName;
|
||||
$scope.formValues.NodeName = nodeName;
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||
@@ -682,6 +684,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
});
|
||||
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
$scope.showDeviceMapping = await shouldShowDevices();
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
@@ -894,6 +897,19 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
}
|
||||
}
|
||||
|
||||
async function shouldShowDevices() {
|
||||
const isAdmin = !$scope.applicationState.application.authentication || Authentication.isAdmin();
|
||||
const { allowDeviceMappingForRegularUsers } = $scope.applicationState.application;
|
||||
|
||||
if (isAdmin || allowDeviceMappingForRegularUsers) {
|
||||
return true;
|
||||
}
|
||||
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
|
||||
if (rbacEnabled) {
|
||||
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -629,7 +629,7 @@
|
||||
</form>
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- devices -->
|
||||
<div class="form-group">
|
||||
<div ng-if="showDeviceMapping" class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Devices</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addDevice()">
|
||||
|
||||
@@ -21,6 +21,7 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
'ImageService',
|
||||
'HttpRequestHelper',
|
||||
'Authentication',
|
||||
'StateManager',
|
||||
function (
|
||||
$q,
|
||||
$scope,
|
||||
@@ -40,7 +41,8 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
RegistryService,
|
||||
ImageService,
|
||||
HttpRequestHelper,
|
||||
Authentication
|
||||
Authentication,
|
||||
StateManager
|
||||
) {
|
||||
$scope.activityTime = 0;
|
||||
$scope.portBindings = [];
|
||||
@@ -94,9 +96,13 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
const inSwarm = $scope.container.Config.Labels['com.docker.swarm.service.id'];
|
||||
const autoRemove = $scope.container.HostConfig.AutoRemove;
|
||||
const admin = Authentication.isAdmin();
|
||||
const appState = StateManager.getState();
|
||||
const { allowHostNamespaceForRegularUsers, allowDeviceMappingForRegularUsers, allowBindMountsForRegularUsers, allowPrivilegedModeForRegularUsers } = appState.application;
|
||||
const settingRestrictsRegularUsers =
|
||||
!allowBindMountsForRegularUsers || !allowDeviceMappingForRegularUsers || !allowHostNamespaceForRegularUsers || !allowPrivilegedModeForRegularUsers;
|
||||
|
||||
ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC).then((rbacEnabled) => {
|
||||
$scope.displayRecreateButton = !inSwarm && !autoRemove && (rbacEnabled ? admin : true);
|
||||
$scope.displayRecreateButton = !inSwarm && !autoRemove && (settingRestrictsRegularUsers || rbacEnabled ? admin : true);
|
||||
});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="col-xs-12 col-md-6" ng-if="showStacks">
|
||||
<a ui-sref="portainer.stacks">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
angular.module('portainer.docker').controller('DashboardController', [
|
||||
'$scope',
|
||||
'$q',
|
||||
'Authentication',
|
||||
'ContainerService',
|
||||
'ImageService',
|
||||
'NetworkService',
|
||||
@@ -11,10 +12,12 @@ angular.module('portainer.docker').controller('DashboardController', [
|
||||
'EndpointService',
|
||||
'Notifications',
|
||||
'EndpointProvider',
|
||||
'ExtensionService',
|
||||
'StateManager',
|
||||
function (
|
||||
$scope,
|
||||
$q,
|
||||
Authentication,
|
||||
ContainerService,
|
||||
ImageService,
|
||||
NetworkService,
|
||||
@@ -25,6 +28,7 @@ angular.module('portainer.docker').controller('DashboardController', [
|
||||
EndpointService,
|
||||
Notifications,
|
||||
EndpointProvider,
|
||||
ExtensionService,
|
||||
StateManager
|
||||
) {
|
||||
$scope.dismissInformationPanel = function (id) {
|
||||
@@ -32,11 +36,14 @@ angular.module('portainer.docker').controller('DashboardController', [
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
$scope.showStacks = false;
|
||||
|
||||
function initView() {
|
||||
async function initView() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
$scope.showStacks = await shouldShowStacks();
|
||||
|
||||
$q.all({
|
||||
containers: ContainerService.containers(1),
|
||||
images: ImageService.images(false),
|
||||
@@ -63,6 +70,19 @@ angular.module('portainer.docker').controller('DashboardController', [
|
||||
});
|
||||
}
|
||||
|
||||
async function shouldShowStacks() {
|
||||
const isAdmin = !$scope.applicationState.application.authentication || Authentication.isAdmin();
|
||||
const { allowStackManagementForRegularUsers } = $scope.applicationState.application;
|
||||
|
||||
if (isAdmin || allowStackManagementForRegularUsers) {
|
||||
return true;
|
||||
}
|
||||
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
|
||||
if (rbacEnabled) {
|
||||
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
<td colspan="2">
|
||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||
|
||||
><p>
|
||||
<a
|
||||
authorization="DockerServiceLogs"
|
||||
|
||||
@@ -72,9 +72,7 @@
|
||||
<input type="checkbox" name="useNFS" ng-model="formValues.NFSData.useNFS" ng-click="formValues.CIFSData.useCIFS = false" />
|
||||
<i></i>
|
||||
</label>
|
||||
<div ng-if="formValues.NFSData.useNFS" class="small text-muted" style="margin-top: 10px;">
|
||||
Ensure <code>nfs-utils</code> are installed on your hosts.
|
||||
</div>
|
||||
<div ng-if="formValues.NFSData.useNFS" class="small text-muted" style="margin-top: 10px;"> Ensure <code>nfs-utils</code> are installed on your hosts. </div>
|
||||
</div>
|
||||
<volumes-nfs-form data="formValues.NFSData" ng-show="formValues.Driver === 'local'"></volumes-nfs-form>
|
||||
<!-- !nfs-management -->
|
||||
@@ -87,9 +85,7 @@
|
||||
<input type="checkbox" name="useCIFS" ng-model="formValues.CIFSData.useCIFS" ng-click="formValues.NFSData.useNFS = false" />
|
||||
<i></i>
|
||||
</label>
|
||||
<div ng-if="formValues.CIFSData.useCIFS" class="small text-muted" style="margin-top: 10px;">
|
||||
Ensure <code>cifs-utils</code> are installed on your hosts.
|
||||
</div>
|
||||
<div ng-if="formValues.CIFSData.useCIFS" class="small text-muted" style="margin-top: 10px;"> Ensure <code>cifs-utils</code> are installed on your hosts. </div>
|
||||
</div>
|
||||
<volumes-cifs-form data="formValues.CIFSData" ng-show="formValues.Driver === 'local'"></volumes-cifs-form>
|
||||
<!-- !cifs-management -->
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
<ui-select
|
||||
multiple
|
||||
ng-model="$ctrl.model"
|
||||
close-on-select="false"
|
||||
>
|
||||
<ui-select multiple ng-model="$ctrl.model" close-on-select="false">
|
||||
<ui-select-match placeholder="Select one or multiple group(s)">
|
||||
<span>
|
||||
{{ $item.Name }}
|
||||
</span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices
|
||||
repeat="item.Id as item in $ctrl.items | filter: { Name: $select.search }"
|
||||
>
|
||||
<ui-select-choices repeat="item.Id as item in $ctrl.items | filter: { Name: $select.search }">
|
||||
<span>
|
||||
{{ item.Name }}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,6 @@ angular.module('portainer.edge').component('edgeGroupsSelector', {
|
||||
templateUrl: './edge-groups-selector.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
items: '<'
|
||||
}
|
||||
items: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
RoleViewModel,
|
||||
// EndpointRoleCreateRequest,
|
||||
// EndpointRoleUpdateRequest
|
||||
} from '../models/role';
|
||||
import { RoleViewModel } from '../models/role';
|
||||
|
||||
angular.module('portainer.extensions.rbac').factory('RoleService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
export function RegistryRepositoryViewModel(item) {
|
||||
if (item.name && item.tags) {
|
||||
this.Name = item.name;
|
||||
|
||||
@@ -42,7 +42,8 @@ angular.module('portainer.extensions.registrymanagement').factory('RegistryGitla
|
||||
|
||||
async function _getRepositoriesPage(params, repositories) {
|
||||
const response = await Gitlab().repositories(params).$promise;
|
||||
repositories = _.concat(repositories, response.data);
|
||||
const filteredRepositories = _.filter(response.data, (repository) => repository.tags && repository.tags.length > 0);
|
||||
repositories = _.concat(repositories, filteredRepositories);
|
||||
if (response.next) {
|
||||
params.page = response.next;
|
||||
repositories = await _getRepositoriesPage(params, repositories);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { RepositoryShortTag } from '../models/repositoryTag';
|
||||
import { RepositoryAddTagPayload } from '../models/repositoryTag';
|
||||
import { RepositoryAddTagPayload, RepositoryShortTag } from '../models/repositoryTag';
|
||||
import { RegistryRepositoryViewModel } from '../models/registryRepository';
|
||||
import genericAsyncGenerator from './genericAsyncGenerator';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { RepositoryTagViewModel, RepositoryShortTag } from '../../../models/repositoryTag';
|
||||
import { RepositoryShortTag, RepositoryTagViewModel } from '../../../models/repositoryTag';
|
||||
|
||||
angular.module('portainer.app').controller('RegistryRepositoryController', [
|
||||
'$q',
|
||||
|
||||
@@ -54,8 +54,8 @@ angular.module('portainer.extensions.registrymanagement').controller('RegistryRe
|
||||
.then(function success() {
|
||||
return RegistryServiceSelector.repositories($scope.registry);
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.repositories = data;
|
||||
.then(function success(repositories) {
|
||||
$scope.repositories = repositories;
|
||||
})
|
||||
.catch(function error() {
|
||||
$scope.state.displayInvalidConfigurationMessage = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StoridgeNodeModel, StoridgeNodeDetailedModel } from '../models/node';
|
||||
import { StoridgeNodeDetailedModel, StoridgeNodeModel } from '../models/node';
|
||||
|
||||
angular.module('portainer.integrations.storidge').factory('StoridgeNodeService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from 'lodash-es';
|
||||
import { ResourceControlOwnership as RCO } from 'Portainer/models/resourceControl/resourceControlOwnership';
|
||||
import { ResourceControlTypeString as RCTS, ResourceControlTypeInt as RCTI } from 'Portainer/models/resourceControl/resourceControlTypes';
|
||||
import { ResourceControlTypeInt as RCTI, ResourceControlTypeString as RCTS } from 'Portainer/models/resourceControl/resourceControlTypes';
|
||||
import { AccessControlPanelData } from './porAccessControlPanelModel';
|
||||
|
||||
angular.module('portainer.app').controller('porAccessControlPanelController', [
|
||||
|
||||
@@ -42,12 +42,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [
|
||||
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
|
||||
};
|
||||
|
||||
this.changeOrderBy = changeOrderBy.bind(this);
|
||||
function changeOrderBy(orderField) {
|
||||
this.changeOrderBy = function changeOrderBy(orderField) {
|
||||
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
|
||||
this.state.orderBy = orderField;
|
||||
DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder);
|
||||
}
|
||||
};
|
||||
|
||||
this.selectItem = function (item, event) {
|
||||
// Handle range select using shift
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="toolBarTitle"><i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="settings">
|
||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
||||
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.stacks.newstack" authorization="PortainerStackCreate">
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.stacks.newstack" ng-disabled="!$ctrl.createEnabled" authorization="PortainerStackCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack
|
||||
</button>
|
||||
</div>
|
||||
@@ -144,7 +144,10 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
|
||||
{{ $ctrl.state.selectedItemCount }}
|
||||
item(s) selected
|
||||
</div>
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
|
||||
@@ -12,5 +12,6 @@ angular.module('portainer.app').component('stacksDatatable', {
|
||||
removeAction: '<',
|
||||
offlineMode: '<',
|
||||
refreshCallback: '<',
|
||||
createEnabled: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
<span ng-if="$item.TagIds.length"> - <i class="fa fa-tags"></i> {{ $ctrl.tagIdsToTagNames($item.TagIds) | arraytostr }}</span>
|
||||
</span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices
|
||||
group-by="$ctrl.groupEndpoints"
|
||||
group-filter="$ctrl.sortGroups"
|
||||
repeat="endpoint.Id as endpoint in $ctrl.endpoints | filter: { Name: $select.search }"
|
||||
>
|
||||
<ui-select-choices group-by="$ctrl.groupEndpoints" group-filter="$ctrl.sortGroups" repeat="endpoint.Id as endpoint in $ctrl.endpoints | filter: { Name: $select.search }">
|
||||
<span>
|
||||
{{ endpoint.Name }}
|
||||
<span ng-if="endpoint.TagIds.length"> - <i class="fa fa-tags"></i> {{ $ctrl.tagIdsToTagNames(endpoint.TagIds) | arraytostr }}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { createStatus } from '../../docker/models/container';
|
||||
import {createStatus} from '../../docker/models/container';
|
||||
|
||||
export function ScheduleDefaultModel() {
|
||||
this.Name = '';
|
||||
|
||||
@@ -13,6 +13,9 @@ export function SettingsViewModel(data) {
|
||||
this.EnableHostManagementFeatures = data.EnableHostManagementFeatures;
|
||||
this.EdgeAgentCheckinInterval = data.EdgeAgentCheckinInterval;
|
||||
this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures;
|
||||
this.AllowStackManagementForRegularUsers = data.AllowStackManagementForRegularUsers;
|
||||
this.AllowHostNamespaceForRegularUsers = data.AllowHostNamespaceForRegularUsers;
|
||||
this.AllowDeviceMappingForRegularUsers = data.AllowDeviceMappingForRegularUsers;
|
||||
}
|
||||
|
||||
export function PublicSettingsViewModel(settings) {
|
||||
@@ -25,6 +28,9 @@ export function PublicSettingsViewModel(settings) {
|
||||
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||
this.LogoURL = settings.LogoURL;
|
||||
this.OAuthLoginURI = settings.OAuthLoginURI;
|
||||
this.AllowStackManagementForRegularUsers = settings.AllowStackManagementForRegularUsers;
|
||||
this.AllowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers;
|
||||
this.AllowHostNamespaceForRegularUsers = settings.AllowHostNamespaceForRegularUsers;
|
||||
}
|
||||
|
||||
export function LDAPSettingsViewModel(data) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { UserAccessViewModel } from '../../models/access';
|
||||
import { TeamAccessViewModel } from '../../models/access';
|
||||
import { TeamAccessViewModel, UserAccessViewModel } from '../../models/access';
|
||||
|
||||
angular.module('portainer.app').factory('AccessService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes';
|
||||
import { RegistryViewModel, RegistryCreateRequest } from '../../models/registry';
|
||||
import { RegistryCreateRequest, RegistryViewModel } from '../../models/registry';
|
||||
|
||||
angular.module('portainer.app').factory('RegistryService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ScheduleModel, ScheduleCreateRequest, ScheduleUpdateRequest, ScriptExecutionTaskModel } from '../../models/schedule';
|
||||
import { ScheduleCreateRequest, ScheduleModel, ScheduleUpdateRequest, ScriptExecutionTaskModel } from '../../models/schedule';
|
||||
|
||||
angular.module('portainer.app').factory('ScheduleService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SettingsViewModel, PublicSettingsViewModel } from '../../models/settings';
|
||||
import { PublicSettingsViewModel, SettingsViewModel } from '../../models/settings';
|
||||
|
||||
angular.module('portainer.app').factory('SettingsService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { StackViewModel, ExternalStackViewModel } from '../../models/stack';
|
||||
import { ExternalStackViewModel, StackViewModel } from '../../models/stack';
|
||||
|
||||
angular.module('portainer.app').factory('StackService', [
|
||||
'$q',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TemplateViewModel, TemplateCreateRequest, TemplateUpdateRequest } from '../../models/template';
|
||||
import { TemplateCreateRequest, TemplateUpdateRequest, TemplateViewModel } from '../../models/template';
|
||||
|
||||
angular.module('portainer.app').factory('TemplateService', [
|
||||
'$q',
|
||||
|
||||
@@ -37,7 +37,7 @@ angular.module('portainer.app').factory('Authentication', [
|
||||
function logout() {
|
||||
StateManager.clean();
|
||||
EndpointProvider.clean();
|
||||
LocalStorage.clean();
|
||||
LocalStorage.cleanAuthData();
|
||||
LocalStorage.storeLoginStateUUID('');
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ angular.module('portainer.app').factory('EndpointProvider', [
|
||||
};
|
||||
|
||||
service.clean = function () {
|
||||
LocalStorage.cleanEndpointData();
|
||||
endpoint = {};
|
||||
};
|
||||
|
||||
|
||||
@@ -91,12 +91,11 @@ angular.module('portainer.app').factory('FileUploadService', [
|
||||
data: {
|
||||
file: file,
|
||||
Name: stackName,
|
||||
EdgeGroups: Upload.json(edgeGroups)
|
||||
EdgeGroups: Upload.json(edgeGroups),
|
||||
},
|
||||
ignoreLoadingBar: true
|
||||
ignoreLoadingBar: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
service.configureRegistry = function (registryId, registryManagementConfigurationModel) {
|
||||
return Upload.upload({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
angular.module('portainer.app').factory('LocalStorage', [
|
||||
'localStorageService',
|
||||
function LocalStorageFactory(localStorageService) {
|
||||
'use strict';
|
||||
return {
|
||||
storeEndpointID: function (id) {
|
||||
localStorageService.set('ENDPOINT_ID', id);
|
||||
@@ -16,10 +15,10 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
return localStorageService.get('ENDPOINT_PUBLIC_URL');
|
||||
},
|
||||
storeLoginStateUUID: function (uuid) {
|
||||
localStorageService.cookie.set('LOGIN_STATE_UUID', uuid);
|
||||
localStorageService.set('LOGIN_STATE_UUID', uuid);
|
||||
},
|
||||
getLoginStateUUID: function () {
|
||||
return localStorageService.cookie.get('LOGIN_STATE_UUID');
|
||||
return localStorageService.get('LOGIN_STATE_UUID');
|
||||
},
|
||||
storeOfflineMode: function (isOffline) {
|
||||
localStorageService.set('ENDPOINT_OFFLINE_MODE', isOffline);
|
||||
@@ -46,10 +45,10 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
return localStorageService.get('APPLICATION_STATE');
|
||||
},
|
||||
storeUIState: function (state) {
|
||||
localStorageService.cookie.set('UI_STATE', state);
|
||||
localStorageService.set('UI_STATE', state);
|
||||
},
|
||||
getUIState: function () {
|
||||
return localStorageService.cookie.get('UI_STATE');
|
||||
return localStorageService.get('UI_STATE');
|
||||
},
|
||||
storeExtensionState: function (state) {
|
||||
localStorageService.set('EXTENSION_STATE', state);
|
||||
@@ -67,40 +66,40 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
localStorageService.remove('JWT');
|
||||
},
|
||||
storePaginationLimit: function (key, count) {
|
||||
localStorageService.cookie.set('datatable_pagination_' + key, count);
|
||||
localStorageService.set('datatable_pagination_' + key, count);
|
||||
},
|
||||
getPaginationLimit: function (key) {
|
||||
return localStorageService.cookie.get('datatable_pagination_' + key);
|
||||
return localStorageService.get('datatable_pagination_' + key);
|
||||
},
|
||||
getDataTableOrder: function (key) {
|
||||
return localStorageService.cookie.get('datatable_order_' + key);
|
||||
return localStorageService.get('datatable_order_' + key);
|
||||
},
|
||||
storeDataTableOrder: function (key, data) {
|
||||
localStorageService.cookie.set('datatable_order_' + key, data);
|
||||
localStorageService.set('datatable_order_' + key, data);
|
||||
},
|
||||
getDataTableTextFilters: function (key) {
|
||||
return localStorageService.cookie.get('datatable_text_filter_' + key);
|
||||
return localStorageService.get('datatable_text_filter_' + key);
|
||||
},
|
||||
storeDataTableTextFilters: function (key, data) {
|
||||
localStorageService.cookie.set('datatable_text_filter_' + key, data);
|
||||
localStorageService.set('datatable_text_filter_' + key, data);
|
||||
},
|
||||
getDataTableFilters: function (key) {
|
||||
return localStorageService.cookie.get('datatable_filters_' + key);
|
||||
return localStorageService.get('datatable_filters_' + key);
|
||||
},
|
||||
storeDataTableFilters: function (key, data) {
|
||||
localStorageService.cookie.set('datatable_filters_' + key, data);
|
||||
localStorageService.set('datatable_filters_' + key, data);
|
||||
},
|
||||
getDataTableSettings: function (key) {
|
||||
return localStorageService.cookie.get('datatable_settings_' + key);
|
||||
return localStorageService.get('datatable_settings_' + key);
|
||||
},
|
||||
storeDataTableSettings: function (key, data) {
|
||||
localStorageService.cookie.set('datatable_settings_' + key, data);
|
||||
localStorageService.set('datatable_settings_' + key, data);
|
||||
},
|
||||
getDataTableExpandedItems: function (key) {
|
||||
return localStorageService.cookie.get('datatable_expandeditems_' + key);
|
||||
return localStorageService.get('datatable_expandeditems_' + key);
|
||||
},
|
||||
storeDataTableExpandedItems: function (key, data) {
|
||||
localStorageService.cookie.set('datatable_expandeditems_' + key, data);
|
||||
localStorageService.set('datatable_expandeditems_' + key, data);
|
||||
},
|
||||
getDataTableSelectedItems: function (key) {
|
||||
return localStorageService.get('datatable_selecteditems_' + key);
|
||||
@@ -109,16 +108,16 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
localStorageService.set('datatable_selecteditems_' + key, data);
|
||||
},
|
||||
storeSwarmVisualizerSettings: function (key, data) {
|
||||
localStorageService.cookie.set('swarmvisualizer_' + key, data);
|
||||
localStorageService.set('swarmvisualizer_' + key, data);
|
||||
},
|
||||
getSwarmVisualizerSettings: function (key) {
|
||||
return localStorageService.cookie.get('swarmvisualizer_' + key);
|
||||
return localStorageService.get('swarmvisualizer_' + key);
|
||||
},
|
||||
storeColumnVisibilitySettings: function (key, data) {
|
||||
localStorageService.cookie.set('col_visibility_' + key, data);
|
||||
localStorageService.set('col_visibility_' + key, data);
|
||||
},
|
||||
getColumnVisibilitySettings: function (key) {
|
||||
return localStorageService.cookie.get('col_visibility_' + key);
|
||||
return localStorageService.get('col_visibility_' + key);
|
||||
},
|
||||
storeJobImage: function (data) {
|
||||
localStorageService.set('job_image', data);
|
||||
@@ -126,12 +125,24 @@ angular.module('portainer.app').factory('LocalStorage', [
|
||||
getJobImage: function () {
|
||||
return localStorageService.get('job_image');
|
||||
},
|
||||
storeToolbarToggle(value) {
|
||||
localStorageService.set('toolbar_toggle', value);
|
||||
},
|
||||
getToolbarToggle() {
|
||||
return localStorageService.get('toolbar_toggle');
|
||||
},
|
||||
storeLogoutReason: (reason) => localStorageService.set('logout_reason', reason),
|
||||
getLogoutReason: () => localStorageService.get('logout_reason'),
|
||||
cleanLogoutReason: () => localStorageService.remove('logout_reason'),
|
||||
clean: function () {
|
||||
localStorageService.clearAll();
|
||||
},
|
||||
cleanAuthData() {
|
||||
localStorageService.remove('JWT', 'EXTENSION_STATE', 'APPLICATION_STATE', 'LOGIN_STATE_UUID');
|
||||
},
|
||||
cleanEndpointData() {
|
||||
localStorageService.remove('ENDPOINT_ID', 'ENDPOINT_PUBLIC_URL', 'ENDPOINT_OFFLINE_MODE', 'ENDPOINTS_DATA', 'ENDPOINT_STATE');
|
||||
},
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -76,6 +76,31 @@ angular.module('portainer.app').factory('StateManager', [
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.updateAllowStackManagementForRegularUsers = function updateAllowStackManagementForRegularUsers(allowStackManagementForRegularUsers) {
|
||||
state.application.allowStackManagementForRegularUsers = allowStackManagementForRegularUsers;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.updateAllowDeviceMappingForRegularUsers = function updateAllowDeviceMappingForRegularUsers(allowDeviceMappingForRegularUsers) {
|
||||
state.application.allowDeviceMappingForRegularUsers = allowDeviceMappingForRegularUsers;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.updateAllowHostNamespaceForRegularUsers = function (allowHostNamespaceForRegularUsers) {
|
||||
state.application.allowHostNamespaceForRegularUsers = allowHostNamespaceForRegularUsers;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.updateAllowBindMountsForRegularUsers = function updateAllowBindMountsForRegularUsers(allowBindMountsForRegularUsers) {
|
||||
state.application.allowBindMountsForRegularUsers = allowBindMountsForRegularUsers;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
manager.updateAllowPrivilegedModeForRegularUsers = function (AllowPrivilegedModeForRegularUsers) {
|
||||
state.application.allowPrivilegedModeForRegularUsers = AllowPrivilegedModeForRegularUsers;
|
||||
LocalStorage.storeApplicationState(state.application);
|
||||
};
|
||||
|
||||
function assignStateFromStatusAndSettings(status, settings) {
|
||||
state.application.authentication = status.Authentication;
|
||||
state.application.analytics = status.Analytics;
|
||||
@@ -87,6 +112,11 @@ angular.module('portainer.app').factory('StateManager', [
|
||||
state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures;
|
||||
state.application.enableVolumeBrowserForNonAdminUsers = settings.AllowVolumeBrowserForRegularUsers;
|
||||
state.application.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||
state.application.allowStackManagementForRegularUsers = settings.AllowStackManagementForRegularUsers;
|
||||
state.application.allowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers;
|
||||
state.application.allowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers;
|
||||
state.application.allowDeviceMappingForRegularUsers = settings.AllowDeviceMappingForRegularUsers;
|
||||
state.application.allowHostNamespaceForRegularUsers = settings.AllowHostNamespaceForRegularUsers;
|
||||
state.application.validity = moment().unix();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
<rd-header-content>User settings</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="UserId === 1">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Feature not available"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<span class="small text-muted">You cannot change the password of this account in the demo version of Portainer.</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
<div style="margin-top: 10px;">
|
||||
<uib-tabset active="state.deploymentTab">
|
||||
<uib-tab index="0" heading="Standalone">
|
||||
<code style="display: block; white-space: pre-wrap; padding: 16px 90px;">{{dockerCommands.standalone}}</code>
|
||||
<code style="display: block; white-space: pre-wrap; padding: 16px 90px;">{{ dockerCommands.standalone }}</code>
|
||||
</uib-tab>
|
||||
<uib-tab index="1" heading="Swarm">
|
||||
<code style="display: block; white-space: pre-wrap; padding: 16px 90px;">{{dockerCommands.swarm}}</code>
|
||||
<code style="display: block; white-space: pre-wrap; padding: 16px 90px;">{{ dockerCommands.swarm }}</code>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
<div style="margin-top: 10px;">
|
||||
|
||||
@@ -102,8 +102,10 @@
|
||||
button-spinner="state.actionInProgress"
|
||||
style="margin-left: 0px;"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress">Enable extension</span>
|
||||
<span ng-show="state.actionInProgress">Enabling extension...</span>
|
||||
<span ng-hide="state.actionInProgress" ng-if="!state.updateLicense">Enable extension</span>
|
||||
<span ng-show="state.actionInProgress" ng-if="!state.updateLicense">Enabling extension...</span>
|
||||
<span ng-hide="state.actionInProgress" ng-if="state.updateLicense">Update license</span>
|
||||
<span ng-show="state.actionInProgress" ng-if="state.updateLicense">Updating license...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash-es';
|
||||
|
||||
angular.module('portainer.app').controller('ExtensionsController', [
|
||||
'$scope',
|
||||
@@ -9,6 +10,7 @@ angular.module('portainer.app').controller('ExtensionsController', [
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
currentDate: moment().format('YYYY-MM-dd'),
|
||||
updateLicense: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
@@ -59,6 +61,15 @@ angular.module('portainer.app').controller('ExtensionsController', [
|
||||
valid = false;
|
||||
}
|
||||
|
||||
const licensePrefix = $scope.formValues.License[0];
|
||||
|
||||
$scope.state.updateLicense = false;
|
||||
_.forEach($scope.extensions, (extension) => {
|
||||
if (licensePrefix === '' + extension.Id && extension.Enabled) {
|
||||
$scope.state.updateLicense = true;
|
||||
}
|
||||
});
|
||||
|
||||
form.extension_license.$setValidity('invalidLicense', valid);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
angular.module('portainer.app').controller('MainController', [
|
||||
'$scope',
|
||||
'$cookieStore',
|
||||
'LocalStorage',
|
||||
'StateManager',
|
||||
'EndpointProvider',
|
||||
function ($scope, $cookieStore, StateManager, EndpointProvider) {
|
||||
function ($scope, LocalStorage, StateManager, EndpointProvider) {
|
||||
/**
|
||||
* Sidebar Toggle & Cookie Control
|
||||
*/
|
||||
@@ -17,11 +17,8 @@ angular.module('portainer.app').controller('MainController', [
|
||||
|
||||
$scope.$watch($scope.getWidth, function (newValue) {
|
||||
if (newValue >= mobileView) {
|
||||
if (angular.isDefined($cookieStore.get('toggle'))) {
|
||||
$scope.toggle = !$cookieStore.get('toggle') ? false : true;
|
||||
} else {
|
||||
$scope.toggle = true;
|
||||
}
|
||||
const toggleValue = LocalStorage.getToolbarToggle();
|
||||
$scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : true;
|
||||
} else {
|
||||
$scope.toggle = false;
|
||||
}
|
||||
@@ -29,7 +26,7 @@ angular.module('portainer.app').controller('MainController', [
|
||||
|
||||
$scope.toggleSidebar = function () {
|
||||
$scope.toggle = !$scope.toggle;
|
||||
$cookieStore.put('toggle', $scope.toggle);
|
||||
LocalStorage.storeToolbarToggle($scope.toggle);
|
||||
};
|
||||
|
||||
window.onresize = function () {
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
<rd-header-content>Settings</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="UserId === 1">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-exclamation-triangle" title="Feature not available"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<span class="small text-muted">You cannot change the password of this account in the demo version of Portainer.</span>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
@@ -23,7 +34,10 @@
|
||||
<label for="toggle_logo" class="control-label text-left">
|
||||
Use custom logo
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" name="toggle_logo" ng-model="formValues.customLogo" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled name="toggle_logo" ng-model="formValues.customLogo" /><i></i> </label>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">You cannot use this feature in the demo version of Portainer.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="formValues.customLogo">
|
||||
@@ -120,6 +134,44 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="toggle_disableStackManagementForRegularUsers" class="control-label text-left">
|
||||
Disable the use of Stacks for non-administrators
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" name="toggle_disableStackManagementForRegularUsers" ng-model="formValues.disableStackManagementForRegularUsers" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="toggle_allowHostNamespaceForRegularUsers" class="control-label text-left">
|
||||
Disable the use of host PID 1 for non-administrators
|
||||
<portainer-tooltip position="bottom" message="Prevent users from accessing the host filesystem through the host PID namespace."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" name="toggle_allowHostNamespaceForRegularUsers" ng-model="formValues.restrictHostNamespaceForRegularUsers" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="toggle_disableDeviceMappingForRegularUsers" class="control-label text-left">
|
||||
Disable device mappings for non-administrators
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" name="toggle_disableDeviceMappingForRegularUsers" ng-model="formValues.disableDeviceMappingForRegularUsers" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="isContainerEditDisabled()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Note: The recreate/duplicate/edit feature is currently disabled (for non-admin users) by one or more security settings.
|
||||
</span>
|
||||
</div>
|
||||
<!-- !security -->
|
||||
<!-- edge -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
|
||||
@@ -33,6 +33,14 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
enableHostManagementFeatures: false,
|
||||
enableVolumeBrowser: false,
|
||||
enableEdgeComputeFeatures: false,
|
||||
allowStackManagementForRegularUsers: false,
|
||||
restrictHostNamespaceForRegularUsers: false,
|
||||
allowDeviceMappingForRegularUsers: false,
|
||||
};
|
||||
|
||||
$scope.isContainerEditDisabled = function isContainerEditDisabled() {
|
||||
const { restrictBindMounts, restrictHostNamespaceForRegularUsers, restrictPrivilegedMode, disableDeviceMappingForRegularUsers } = this.formValues;
|
||||
return restrictBindMounts || restrictHostNamespaceForRegularUsers || restrictPrivilegedMode || disableDeviceMappingForRegularUsers;
|
||||
};
|
||||
|
||||
$scope.removeFilteredContainerLabel = function (index) {
|
||||
@@ -69,6 +77,9 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
settings.AllowVolumeBrowserForRegularUsers = $scope.formValues.enableVolumeBrowser;
|
||||
settings.EnableHostManagementFeatures = $scope.formValues.enableHostManagementFeatures;
|
||||
settings.EnableEdgeComputeFeatures = $scope.formValues.enableEdgeComputeFeatures;
|
||||
settings.AllowStackManagementForRegularUsers = !$scope.formValues.disableStackManagementForRegularUsers;
|
||||
settings.AllowHostNamespaceForRegularUsers = !$scope.formValues.restrictHostNamespaceForRegularUsers;
|
||||
settings.AllowDeviceMappingForRegularUsers = !$scope.formValues.disableDeviceMappingForRegularUsers;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
updateSettings(settings);
|
||||
@@ -83,6 +94,11 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
StateManager.updateEnableHostManagementFeatures(settings.EnableHostManagementFeatures);
|
||||
StateManager.updateEnableVolumeBrowserForNonAdminUsers(settings.AllowVolumeBrowserForRegularUsers);
|
||||
StateManager.updateEnableEdgeComputeFeatures(settings.EnableEdgeComputeFeatures);
|
||||
StateManager.updateAllowStackManagementForRegularUsers(settings.AllowStackManagementForRegularUsers);
|
||||
StateManager.updateAllowHostNamespaceForRegularUsers(settings.AllowHostNamespaceForRegularUsers);
|
||||
StateManager.updateAllowDeviceMappingForRegularUsers(settings.AllowDeviceMappingForRegularUsers);
|
||||
StateManager.updateAllowPrivilegedModeForRegularUsers(settings.AllowPrivilegedModeForRegularUsers);
|
||||
StateManager.updateAllowBindMountsForRegularUsers(settings.AllowBindMountsForRegularUsers);
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
@@ -109,6 +125,9 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
$scope.formValues.enableVolumeBrowser = settings.AllowVolumeBrowserForRegularUsers;
|
||||
$scope.formValues.enableHostManagementFeatures = settings.EnableHostManagementFeatures;
|
||||
$scope.formValues.enableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||
$scope.formValues.disableStackManagementForRegularUsers = !settings.AllowStackManagementForRegularUsers;
|
||||
$scope.formValues.restrictHostNamespaceForRegularUsers = !settings.AllowHostNamespaceForRegularUsers;
|
||||
$scope.formValues.disableDeviceMappingForRegularUsers = !settings.AllowDeviceMappingForRegularUsers;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'"
|
||||
admin-access="!applicationState.application.authentication || isAdmin"
|
||||
offline-mode="endpointState.OfflineMode"
|
||||
show-stacks="showStacks"
|
||||
></docker-sidebar-content>
|
||||
<li class="sidebar-title" authorization="IntegrationStoridgeAdmin" ng-if="applicationState.endpoint.mode && applicationState.endpoint.extensions.length > 0">
|
||||
<span>Integrations</span>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
angular.module('portainer.app').controller('SidebarController', [
|
||||
'$q',
|
||||
'$scope',
|
||||
'$transitions',
|
||||
'StateManager',
|
||||
'Notifications',
|
||||
'Authentication',
|
||||
'UserService',
|
||||
function ($q, $scope, StateManager, Notifications, Authentication, UserService) {
|
||||
'ExtensionService',
|
||||
function ($q, $scope, $transitions, StateManager, Notifications, Authentication, UserService, ExtensionService) {
|
||||
function checkPermissions(memberships) {
|
||||
var isLeader = false;
|
||||
angular.forEach(memberships, function (membership) {
|
||||
@@ -16,9 +18,10 @@ angular.module('portainer.app').controller('SidebarController', [
|
||||
$scope.isTeamLeader = isLeader;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
async function initView() {
|
||||
$scope.uiVersion = StateManager.getState().application.version;
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
$scope.showStacks = await shouldShowStacks();
|
||||
|
||||
var authenticationEnabled = $scope.applicationState.application.authentication;
|
||||
if (authenticationEnabled) {
|
||||
@@ -37,5 +40,24 @@ angular.module('portainer.app').controller('SidebarController', [
|
||||
}
|
||||
|
||||
initView();
|
||||
|
||||
async function shouldShowStacks() {
|
||||
const isAdmin = !$scope.applicationState.application.authentication || Authentication.isAdmin();
|
||||
const { allowStackManagementForRegularUsers } = $scope.applicationState.application;
|
||||
|
||||
if (isAdmin || allowStackManagementForRegularUsers) {
|
||||
return true;
|
||||
}
|
||||
const rbacEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
|
||||
if (rbacEnabled) {
|
||||
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$transitions.onEnter({}, async () => {
|
||||
$scope.showStacks = await shouldShowStacks();
|
||||
});
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
show-ownership-column="applicationState.application.authentication"
|
||||
offline-mode="offlineMode"
|
||||
refresh-callback="getStacks"
|
||||
create-enabled="createEnabled"
|
||||
></stacks-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,85 @@
|
||||
angular.module('portainer.app').controller('StacksController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'Notifications',
|
||||
'StackService',
|
||||
'ModalService',
|
||||
'EndpointProvider',
|
||||
function ($scope, $state, Notifications, StackService, ModalService, EndpointProvider) {
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
ModalService.confirmDeletion('Do you want to remove the selected stack(s)? Associated services will be removed as well.', function onConfirm(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
deleteSelectedStacks(selectedItems);
|
||||
});
|
||||
};
|
||||
angular.module('portainer.app').controller('StacksController', StacksController);
|
||||
|
||||
function deleteSelectedStacks(stacks) {
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
var actionCount = stacks.length;
|
||||
angular.forEach(stacks, function (stack) {
|
||||
StackService.remove(stack, stack.External, endpointId)
|
||||
.then(function success() {
|
||||
Notifications.success('Stack successfully removed', stack.Name);
|
||||
var index = $scope.stacks.indexOf(stack);
|
||||
$scope.stacks.splice(index, 1);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
|
||||
})
|
||||
.finally(function final() {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
$state.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
/* @ngInject */
|
||||
function StacksController($scope, $state, Notifications, StackService, ModalService, EndpointProvider, Authentication, StateManager, ExtensionService) {
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
ModalService.confirmDeletion('Do you want to remove the selected stack(s)? Associated services will be removed as well.', function onConfirm(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
deleteSelectedStacks(selectedItems);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.offlineMode = false;
|
||||
|
||||
$scope.getStacks = getStacks;
|
||||
function getStacks() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId)
|
||||
.then(function success(data) {
|
||||
var stacks = data;
|
||||
$scope.stacks = stacks;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
function deleteSelectedStacks(stacks) {
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
var actionCount = stacks.length;
|
||||
angular.forEach(stacks, function (stack) {
|
||||
StackService.remove(stack, stack.External, endpointId)
|
||||
.then(function success() {
|
||||
Notifications.success('Stack successfully removed', stack.Name);
|
||||
var index = $scope.stacks.indexOf(stack);
|
||||
$scope.stacks.splice(index, 1);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.stacks = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve stacks');
|
||||
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
|
||||
})
|
||||
.finally(function final() {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
$state.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$scope.offlineMode = false;
|
||||
$scope.createEnabled = false;
|
||||
|
||||
$scope.getStacks = getStacks;
|
||||
|
||||
function getStacks() {
|
||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||
var endpointId = EndpointProvider.endpointID();
|
||||
|
||||
StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpointId)
|
||||
.then(function success(data) {
|
||||
var stacks = data;
|
||||
$scope.stacks = stacks;
|
||||
$scope.offlineMode = EndpointProvider.offlineMode();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.stacks = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve stacks');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCreateEnabled() {
|
||||
const appState = StateManager.getState().application;
|
||||
if (appState.allowStackManagementForRegularUsers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
getStacks();
|
||||
let isAdmin = true;
|
||||
if (appState.authentication) {
|
||||
isAdmin = Authentication.isAdmin();
|
||||
}
|
||||
if (isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
const RBACExtensionEnabled = await ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.RBAC);
|
||||
if (!RBACExtensionEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Authentication.hasAuthorizations(['EndpointResourcesAccess']);
|
||||
}
|
||||
|
||||
async function initView() {
|
||||
getStacks();
|
||||
$scope.createEnabled = await loadCreateEnabled();
|
||||
}
|
||||
|
||||
initView();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import 'angular-moment-picker/dist/angular-moment-picker.min.css';
|
||||
import 'angular-multiselect/isteven-multi-select.css';
|
||||
|
||||
import angular from 'angular';
|
||||
window.angular = angular;
|
||||
import 'moment';
|
||||
import '@uirouter/angularjs';
|
||||
import 'ui-select';
|
||||
@@ -39,3 +38,5 @@ import 'js-yaml/dist/js-yaml.js';
|
||||
import 'angular-ui-bootstrap';
|
||||
import 'angular-moment-picker';
|
||||
import 'angular-multiselect/isteven-multi-select.js';
|
||||
|
||||
window.angular = angular;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: portainer
|
||||
Version: 1.24.0
|
||||
Version: 1.24.1
|
||||
Release: 0
|
||||
License: Zlib
|
||||
Summary: A lightweight docker management UI
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "1.24.0",
|
||||
"version": "1.24.1",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
Reference in New Issue
Block a user