diff --git a/api/bolt/migrate_dbversion2.go b/api/bolt/migrate_dbversion2.go index 86059736d..38a3e4b50 100644 --- a/api/bolt/migrate_dbversion2.go +++ b/api/bolt/migrate_dbversion2.go @@ -2,7 +2,7 @@ package bolt import "github.com/portainer/portainer" -func (m *Migrator) updateSettingsToVersion3() error { +func (m *Migrator) updateSettingsToDBVersion3() error { legacySettings, err := m.SettingsService.Settings() if err != nil { return err diff --git a/api/bolt/migrate_dbversion3.go b/api/bolt/migrate_dbversion3.go new file mode 100644 index 000000000..d8679ca68 --- /dev/null +++ b/api/bolt/migrate_dbversion3.go @@ -0,0 +1,27 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToDBVersion4() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.TLSConfig = portainer.TLSConfiguration{} + if endpoint.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = endpoint.TLSCACertPath + endpoint.TLSConfig.TLSCertPath = endpoint.TLSCertPath + endpoint.TLSConfig.TLSKeyPath = endpoint.TLSKeyPath + } + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 297afb867..faba56157 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -30,7 +30,7 @@ func NewMigrator(store *Store, version int) *Migrator { func (m *Migrator) Migrate() error { // Portainer < 1.12 - if m.CurrentDBVersion == 0 { + if m.CurrentDBVersion < 1 { err := m.updateAdminUserToDBVersion1() if err != nil { return err @@ -38,7 +38,7 @@ func (m *Migrator) Migrate() error { } // Portainer 1.12.x - if m.CurrentDBVersion == 1 { + if m.CurrentDBVersion < 2 { err := m.updateResourceControlsToDBVersion2() if err != nil { return err @@ -50,8 +50,16 @@ func (m *Migrator) Migrate() error { } // Portainer 1.13.x - if m.CurrentDBVersion == 2 { - err := m.updateSettingsToVersion3() + if m.CurrentDBVersion < 3 { + err := m.updateSettingsToDBVersion3() + if err != nil { + return err + } + } + + // Portainer 1.14.0 + if m.CurrentDBVersion < 4 { + err := m.updateEndpointsToDBVersion4() if err != nil { return err } diff --git a/api/cli/cli.go b/api/cli/cli.go index 80308d41a..dfca35923 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -36,7 +36,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), - NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(), + NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index aced452a7..6f202ec32 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -191,12 +191,15 @@ func main() { } if len(endpoints) == 0 { endpoint := &portainer.Endpoint{ - Name: "primary", - URL: *flags.Endpoint, - TLS: *flags.TLSVerify, - TLSCACertPath: *flags.TLSCacert, - TLSCertPath: *flags.TLSCert, - TLSKeyPath: *flags.TLSKey, + Name: "primary", + URL: *flags.Endpoint, + TLSConfig: portainer.TLSConfiguration{ + TLS: *flags.TLSVerify, + TLSSkipVerify: false, + TLSCACertPath: *flags.TLSCacert, + TLSCertPath: *flags.TLSCert, + TLSKeyPath: *flags.TLSKey, + }, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, } @@ -245,7 +248,7 @@ func main() { SSLKey: *flags.SSLKey, } - log.Printf("Starting Portainer on %s", *flags.Addr) + log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) err = server.Start() if err != nil { log.Fatal(err) diff --git a/api/cron/endpoint_sync.go b/api/cron/endpoint_sync.go index 9fbb634dc..6b9926665 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/endpoint_sync.go @@ -22,6 +22,16 @@ type ( endpointsToUpdate []*portainer.Endpoint endpointsToDelete []*portainer.Endpoint } + + fileEndpoint struct { + Name string `json:"Name"` + URL string `json:"URL"` + TLS bool `json:"TLS,omitempty"` + TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"` + TLSCACert string `json:"TLSCACert,omitempty"` + TLSCert string `json:"TLSCert,omitempty"` + TLSKey string `json:"TLSKey,omitempty"` + } ) const ( @@ -55,6 +65,28 @@ func isValidEndpoint(endpoint *portainer.Endpoint) bool { return false } +func convertFileEndpoints(fileEndpoints []fileEndpoint) []portainer.Endpoint { + convertedEndpoints := make([]portainer.Endpoint, 0) + + for _, e := range fileEndpoints { + endpoint := portainer.Endpoint{ + Name: e.Name, + URL: e.URL, + TLSConfig: portainer.TLSConfiguration{}, + } + if e.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = e.TLSSkipVerify + endpoint.TLSConfig.TLSCACertPath = e.TLSCACert + endpoint.TLSConfig.TLSCertPath = e.TLSCert + endpoint.TLSConfig.TLSKeyPath = e.TLSKey + } + convertedEndpoints = append(convertedEndpoints, endpoint) + } + + return convertedEndpoints +} + func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int { for idx, v := range endpoints { if endpoint.Name == v.Name && isValidEndpoint(&v) { @@ -66,22 +98,25 @@ func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint { var endpoint *portainer.Endpoint - if original.URL != updated.URL || original.TLS != updated.TLS || - (updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) || - (updated.TLS && original.TLSCertPath != updated.TLSCertPath) || - (updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) { + if original.URL != updated.URL || original.TLSConfig.TLS != updated.TLSConfig.TLS || + (updated.TLSConfig.TLS && original.TLSConfig.TLSSkipVerify != updated.TLSConfig.TLSSkipVerify) || + (updated.TLSConfig.TLS && original.TLSConfig.TLSCACertPath != updated.TLSConfig.TLSCACertPath) || + (updated.TLSConfig.TLS && original.TLSConfig.TLSCertPath != updated.TLSConfig.TLSCertPath) || + (updated.TLSConfig.TLS && original.TLSConfig.TLSKeyPath != updated.TLSConfig.TLSKeyPath) { endpoint = original endpoint.URL = updated.URL - if updated.TLS { - endpoint.TLS = true - endpoint.TLSCACertPath = updated.TLSCACertPath - endpoint.TLSCertPath = updated.TLSCertPath - endpoint.TLSKeyPath = updated.TLSKeyPath + if updated.TLSConfig.TLS { + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = updated.TLSConfig.TLSSkipVerify + endpoint.TLSConfig.TLSCACertPath = updated.TLSConfig.TLSCACertPath + endpoint.TLSConfig.TLSCertPath = updated.TLSConfig.TLSCertPath + endpoint.TLSConfig.TLSKeyPath = updated.TLSConfig.TLSKeyPath } else { - endpoint.TLS = false - endpoint.TLSCACertPath = "" - endpoint.TLSCertPath = "" - endpoint.TLSKeyPath = "" + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" } } return endpoint @@ -141,7 +176,7 @@ func (job endpointSyncJob) Sync() error { return err } - var fileEndpoints []portainer.Endpoint + var fileEndpoints []fileEndpoint err = json.Unmarshal(data, &fileEndpoints) if endpointSyncError(err, job.logger) { return err @@ -156,7 +191,9 @@ func (job endpointSyncJob) Sync() error { return err } - sync := job.prepareSyncData(storedEndpoints, fileEndpoints) + convertedFileEndpoints := convertFileEndpoints(fileEndpoints) + + sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints) if sync.requireSync() { err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) if endpointSyncError(err, job.logger) { diff --git a/api/crypto/tls.go b/api/crypto/tls.go index 9c3f1f192..3d22091d8 100644 --- a/api/crypto/tls.go +++ b/api/crypto/tls.go @@ -4,31 +4,38 @@ import ( "crypto/tls" "crypto/x509" "io/ioutil" + + "github.com/portainer/portainer" ) // CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key -func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) { +func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) { + TLSConfig := &tls.Config{} - config := &tls.Config{} + if config.TLS { + if config.TLSCertPath != "" && config.TLSKeyPath != "" { + cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath) + if err != nil { + return nil, err + } - if certPath != "" && keyPath != "" { - cert, err := tls.LoadX509KeyPair(certPath, keyPath) - if err != nil { - return nil, err + TLSConfig.Certificates = []tls.Certificate{cert} } - config.Certificates = []tls.Certificate{cert} + + if !config.TLSSkipVerify { + caCert, err := ioutil.ReadFile(config.TLSCACertPath) + if err != nil { + return nil, err + } + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + TLSConfig.RootCAs = caCertPool + } + + TLSConfig.InsecureSkipVerify = config.TLSSkipVerify } - if caCertPath != "" { - caCert, err := ioutil.ReadFile(caCertPath) - if err != nil { - return nil, err - } - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - config.RootCAs = caCertPool - } - - config.InsecureSkipVerify = skipTLSVerify - return config, nil + return TLSConfig, nil } diff --git a/api/errors.go b/api/errors.go index bf5f5517a..aefd967b7 100644 --- a/api/errors.go +++ b/api/errors.go @@ -13,8 +13,10 @@ const ( const ( ErrUserNotFound = Error("User not found") ErrUserAlreadyExists = Error("User already exists") - ErrInvalidUsername = Error("Invalid username. White spaces are not allowed.") - ErrAdminAlreadyInitialized = Error("Admin user already initialized") + ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") + ErrAdminAlreadyInitialized = Error("An administrator user already exists") + ErrCannotRemoveAdmin = Error("Cannot remove the default administrator account") + ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") ) // Team errors. diff --git a/api/file/file.go b/api/file/file.go index 75bcb99ec..c143fce0b 100644 --- a/api/file/file.go +++ b/api/file/file.go @@ -95,7 +95,7 @@ func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSF return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil } -// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint. +// DeleteTLSFiles deletes a folder in the TLS store path. func (service *Service) DeleteTLSFiles(folder string) error { storePath := path.Join(service.fileStorePath, TLSStorePath, folder) err := os.RemoveAll(storePath) @@ -105,6 +105,29 @@ func (service *Service) DeleteTLSFiles(folder string) error { return nil } +// DeleteTLSFile deletes a specific TLS file from a folder. +func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileType) error { + var fileName string + switch fileType { + case portainer.TLSFileCA: + fileName = TLSCACertFile + case portainer.TLSFileCert: + fileName = TLSCertFile + case portainer.TLSFileKey: + fileName = TLSKeyFile + default: + return portainer.ErrUndefinedTLSFileType + } + + filePath := path.Join(service.fileStorePath, TLSStorePath, folder, fileName) + + err := os.Remove(filePath) + if err != nil { + return err + } + return nil +} + // createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system. func (service *Service) createDirectoryInStoreIfNotExist(name string) error { path := path.Join(service.fileStorePath, name) diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 07950d790..1b052847e 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -57,10 +57,12 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag type ( postEndpointsRequest struct { - Name string `valid:"required"` - URL string `valid:"required"` - PublicURL string `valid:"-"` - TLS bool + Name string `valid:"required"` + URL string `valid:"required"` + PublicURL string `valid:"-"` + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool } postEndpointsResponse struct { @@ -73,10 +75,12 @@ type ( } putEndpointsRequest struct { - Name string `valid:"-"` - URL string `valid:"-"` - PublicURL string `valid:"-"` - TLS bool `valid:"-"` + Name string `valid:"-"` + URL string `valid:"-"` + PublicURL string `valid:"-"` + TLS bool `valid:"-"` + TLSSkipVerify bool `valid:"-"` + TLSSkipClientVerify bool `valid:"-"` } ) @@ -123,10 +127,13 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht } endpoint := &portainer.Endpoint{ - Name: req.Name, - URL: req.URL, - PublicURL: req.PublicURL, - TLS: req.TLS, + Name: req.Name, + URL: req.URL, + PublicURL: req.PublicURL, + TLSConfig: portainer.TLSConfiguration{ + TLS: req.TLS, + TLSSkipVerify: req.TLSSkipVerify, + }, AuthorizedUsers: []portainer.UserID{}, AuthorizedTeams: []portainer.TeamID{}, } @@ -139,12 +146,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht if req.TLS { folder := strconv.Itoa(int(endpoint.ID)) - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSCACertPath = caCertPath - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSKeyPath = keyPath + + if !req.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } + + if !req.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } + err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -284,18 +298,33 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http folder := strconv.Itoa(int(endpoint.ID)) if req.TLS { - endpoint.TLS = true - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSCACertPath = caCertPath - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSKeyPath = keyPath + endpoint.TLSConfig.TLS = true + endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify + if !req.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + + if !req.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } } else { - endpoint.TLS = false - endpoint.TLSCACertPath = "" - endpoint.TLSCertPath = "" - endpoint.TLSKeyPath = "" + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = true + endpoint.TLSConfig.TLSCACertPath = "" + endpoint.TLSConfig.TLSCertPath = "" + endpoint.TLSConfig.TLSKeyPath = "" err = handler.FileService.DeleteTLSFiles(folder) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -350,7 +379,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h return } - if endpoint.TLS { + if endpoint.TLSConfig.TLS { err = handler.FileService.DeleteTLSFiles(id) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) diff --git a/api/http/handler/file.go b/api/http/handler/file.go index 488a3968e..2191169ac 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file.go @@ -30,6 +30,7 @@ func NewFileHandler(assetPath string) *FileHandler { "/js": true, "/images": true, "/fonts": true, + "/ico": true, }, } return h diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 7c35dec39..623e595e9 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -78,6 +78,10 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.ServiceResourceControl case "volume": resourceControlType = portainer.VolumeResourceControl + case "network": + resourceControlType = portainer.NetworkResourceControl + case "secret": + resourceControlType = portainer.SecretResourceControl default: httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) return diff --git a/api/http/handler/user.go b/api/http/handler/user.go index 7aa4e11c9..72952737d 100644 --- a/api/http/handler/user.go +++ b/api/http/handler/user.go @@ -82,6 +82,7 @@ type ( } postAdminInitRequest struct { + Username string `valid:"required"` Password string `valid:"required"` } ) @@ -358,10 +359,14 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R return } - user, err := handler.UserService.UserByUsername("admin") - if err == portainer.ErrUserNotFound { + users, err := handler.UserService.UsersByRole(portainer.AdministratorRole) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + if len(users) == 0 { user := &portainer.User{ - Username: "admin", + Username: req.Username, Role: portainer.AdministratorRole, } user.Password, err = handler.CryptoService.Hash(req.Password) @@ -375,11 +380,7 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - } else if err != nil { - httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) - return - } - if user != nil { + } else { httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger) return } @@ -396,6 +397,22 @@ func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Requ return } + if userID == 1 { + httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveAdmin, http.StatusForbidden, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.ID == portainer.UserID(userID) { + httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger) + return + } + _, err = handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrUserNotFound { diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket.go index dbc4fd9f0..b57f02806 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket.go @@ -71,8 +71,8 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { // Should not be managed here var tlsConfig *tls.Config - if endpoint.TLS { - tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false) + if endpoint.TLSConfig.TLS { + tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) if err != nil { log.Fatalf("Unable to create TLS configuration: %s", err) return diff --git a/api/http/proxy/decorator.go b/api/http/proxy/decorator.go index cc35fa7a3..ff075cc69 100644 --- a/api/http/proxy/decorator.go +++ b/api/http/proxy/decorator.go @@ -82,6 +82,54 @@ func decorateServiceList(serviceData []interface{}, resourceControls []portainer return decoratedServiceData, nil } +// decorateNetworkList loops through all networks and will decorate any network with an existing resource control. +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + resourceControl := getResourceControlByResourceID(networkID, resourceControls) + if resourceControl != nil { + networkObject = decorateObject(networkObject, resourceControl) + } + + decoratedNetworkData = append(decoratedNetworkData, networkObject) + } + + return decoratedNetworkData, nil +} + +// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control. +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + resourceControl := getResourceControlByResourceID(secretID, resourceControls) + if resourceControl != nil { + secretObject = decorateObject(secretObject, resourceControl) + } + + decoratedSecretData = append(decoratedSecretData, secretObject) + } + + return decoratedSecretData, nil +} + func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { metadata := make(map[string]interface{}) metadata["ResourceControl"] = resourceControl diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index dc733149f..210ef54a0 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -24,7 +24,7 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { u.Scheme = "https" proxy := factory.createReverseProxy(u) - config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false) + config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) if err != nil { return nil, err } diff --git a/api/http/proxy/filter.go b/api/http/proxy/filter.go index 0e66ab4fd..005b11469 100644 --- a/api/http/proxy/filter.go +++ b/api/http/proxy/filter.go @@ -110,3 +110,76 @@ func filterServiceList(serviceData []interface{}, resourceControls []portainer.R return filteredServiceData, nil } + +// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with +// any resource control giving access to the user (these networks will be decorated). +// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList +func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredNetworkData := make([]interface{}, 0) + + for _, network := range networkData { + networkObject := network.(map[string]interface{}) + if networkObject[networkIdentifier] == nil { + return nil, ErrDockerNetworkIdentifierNotFound + } + + networkID := networkObject[networkIdentifier].(string) + resourceControl := getResourceControlByResourceID(networkID, resourceControls) + if resourceControl == nil { + filteredNetworkData = append(filteredNetworkData, networkObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + networkObject = decorateObject(networkObject, resourceControl) + filteredNetworkData = append(filteredNetworkData, networkObject) + } + } + + return filteredNetworkData, nil +} + +// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with +// any resource control giving access to the user (these secrets will be decorated). +// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList +func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredSecretData := make([]interface{}, 0) + + for _, secret := range secretData { + secretObject := secret.(map[string]interface{}) + if secretObject[secretIdentifier] == nil { + return nil, ErrDockerSecretIdentifierNotFound + } + + secretID := secretObject[secretIdentifier].(string) + resourceControl := getResourceControlByResourceID(secretID, resourceControls) + if resourceControl == nil { + filteredSecretData = append(filteredSecretData, secretObject) + } else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) { + secretObject = decorateObject(secretObject, resourceControl) + filteredSecretData = append(filteredSecretData, secretObject) + } + } + + return filteredSecretData, nil +} + +// filterTaskList loops through all tasks, filters tasks without any resource control (public resources) or with +// any resource control giving access to the user based on the associated service identifier. +// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList +func filterTaskList(taskData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) { + filteredTaskData := make([]interface{}, 0) + + for _, task := range taskData { + taskObject := task.(map[string]interface{}) + if taskObject[taskServiceIdentifier] == nil { + return nil, ErrDockerTaskServiceIdentifierNotFound + } + + serviceID := taskObject[taskServiceIdentifier].(string) + + resourceControl := getResourceControlByResourceID(serviceID, resourceControls) + if resourceControl == nil || (resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl)) { + filteredTaskData = append(filteredTaskData, taskObject) + } + } + + return filteredTaskData, nil +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 8710c7a44..bdba2b216 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -37,7 +37,7 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht } if endpointURL.Scheme == "tcp" { - if endpoint.TLS { + if endpoint.TLSConfig.TLS { proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint) if err != nil { return nil, err diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go new file mode 100644 index 000000000..2e2549408 --- /dev/null +++ b/api/http/proxy/networks.go @@ -0,0 +1,66 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier + ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") + networkIdentifier = "Id" +) + +// networkListOperation extracts the response as a JSON object, loop through the networks array +// decorate and/or filter the networks based on resource controls before rewriting the response +func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + // NetworkList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// networkInspectOperation extracts the response as a JSON object, verify that the user +// has access to the network based on resource control and either rewrite an access denied response +// or a decorated network. +func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // NetworkInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[networkIdentifier] == nil { + return ErrDockerNetworkIdentifierNotFound + } + networkID := responseObject[networkIdentifier].(string) + + resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls) + if resourceControl != nil { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, + executor.operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go new file mode 100644 index 000000000..d0001d3b9 --- /dev/null +++ b/api/http/proxy/secrets.go @@ -0,0 +1,67 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier + ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found") + secretIdentifier = "ID" +) + +// secretListOperation extracts the response as a JSON object, loop through the secrets array +// decorate and/or filter the secrets based on resource controls before rewriting the response +func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + + // SecretList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/SecretList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// secretInspectOperation extracts the response as a JSON object, verify that the user +// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated secret. +func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // SecretInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[secretIdentifier] == nil { + return ErrDockerSecretIdentifierNotFound + } + secretID := responseObject[secretIdentifier].(string) + + resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls) + if resourceControl != nil { + if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, + executor.operationContext.userTeamIDs, resourceControl) { + responseObject = decorateObject(responseObject, resourceControl) + } else { + return rewriteAccessDeniedResponse(response) + } + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} diff --git a/api/http/proxy/socket.go b/api/http/proxy/socket.go index 740010a63..5a9158492 100644 --- a/api/http/proxy/socket.go +++ b/api/http/proxy/socket.go @@ -34,6 +34,9 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Add(k, v) } } + + w.WriteHeader(res.StatusCode) + if _, err := io.Copy(w, res.Body); err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil) } diff --git a/api/http/proxy/tasks.go b/api/http/proxy/tasks.go new file mode 100644 index 000000000..b9073458b --- /dev/null +++ b/api/http/proxy/tasks.go @@ -0,0 +1,36 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task + ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found") + taskServiceIdentifier = "ServiceID" +) + +// taskListOperation extracts the response as a JSON object, loop through the tasks array +// and filter the tasks based on resource controls before rewriting the response +func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + + // TaskList response is a JSON array + // https://docs.docker.com/engine/api/v1.28/#operation/TaskList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if !executor.operationContext.isAdmin { + responseArray, err = filterTaskList(responseArray, executor.operationContext.resourceControls, + executor.operationContext.userID, executor.operationContext.userTeamIDs) + if err != nil { + return err + } + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index 76c4f43f7..83f746d0e 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -53,17 +53,26 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { path := request.URL.Path - if strings.HasPrefix(path, "/containers") { + switch { + case strings.HasPrefix(path, "/containers"): return p.proxyContainerRequest(request) - } else if strings.HasPrefix(path, "/services") { + case strings.HasPrefix(path, "/services"): return p.proxyServiceRequest(request) - } else if strings.HasPrefix(path, "/volumes") { + case strings.HasPrefix(path, "/volumes"): return p.proxyVolumeRequest(request) - } else if strings.HasPrefix(path, "/swarm") { + case strings.HasPrefix(path, "/networks"): + return p.proxyNetworkRequest(request) + case strings.HasPrefix(path, "/secrets"): + return p.proxySecretRequest(request) + case strings.HasPrefix(path, "/swarm"): return p.proxySwarmRequest(request) + case strings.HasPrefix(path, "/nodes"): + return p.proxyNodeRequest(request) + case strings.HasPrefix(path, "/tasks"): + return p.proxyTaskRequest(request) + default: + return p.executeDockerRequest(request) } - - return p.executeDockerRequest(request) } func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { @@ -145,10 +154,67 @@ func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Respon } } +func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/networks/create": + return p.executeDockerRequest(request) + + case "/networks": + return p.rewriteOperation(request, networkListOperation) + + default: + // assume /networks/{id} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, networkInspectOperation) + } + networkID := path.Base(requestPath) + return p.restrictedOperation(request, networkID) + } +} + +func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/secrets/create": + return p.executeDockerRequest(request) + + case "/secrets": + return p.rewriteOperation(request, secretListOperation) + + default: + // assume /secrets/{id} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, secretInspectOperation) + } + secretID := path.Base(requestPath) + return p.restrictedOperation(request, secretID) + } +} + +func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response, error) { + requestPath := request.URL.Path + + // assume /nodes/{id} + if path.Base(requestPath) != "nodes" { + return p.administratorOperation(request) + } + + return p.executeDockerRequest(request) +} + func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { return p.administratorOperation(request) } +func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/tasks": + return p.rewriteOperation(request, taskListOperation) + default: + // assume /tasks/{id} + return p.executeDockerRequest(request) + } +} + // restrictedOperation ensures that the current user has the required authorizations // before executing the original request. func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) { diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 786332c35..8a280f754 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -52,7 +52,7 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) { if settings.TLSConfig.TLS || settings.StartTLS { - config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify) + config, err := crypto.CreateTLSConfiguration(&settings.TLSConfig) if err != nil { return nil, err } diff --git a/api/portainer.go b/api/portainer.go index 69d8e9290..b641ef5cd 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -155,16 +155,20 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - PublicURL string `json:"PublicURL"` - TLS bool `json:"TLS"` - TLSCACertPath string `json:"TLSCACert,omitempty"` - TLSCertPath string `json:"TLSCert,omitempty"` - TLSKeyPath string `json:"TLSKey,omitempty"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + URL string `json:"URL"` + PublicURL string `json:"PublicURL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + + // Deprecated fields + // Deprecated in DBVersion == 4 + TLS bool `json:"TLS,omitempty"` + TLSCACertPath string `json:"TLSCACert,omitempty"` + TLSCertPath string `json:"TLSCert,omitempty"` + TLSKeyPath string `json:"TLSKey,omitempty"` } // ResourceControlID represents a resource control identifier. @@ -172,20 +176,18 @@ type ( // ResourceControl represent a reference to a Docker resource with specific access controls ResourceControl struct { - ID ResourceControlID `json:"Id"` - ResourceID string `json:"ResourceId"` - SubResourceIDs []string `json:"SubResourceIds"` - Type ResourceControlType `json:"Type"` - AdministratorsOnly bool `json:"AdministratorsOnly"` - - UserAccesses []UserResourceAccess `json:"UserAccesses"` - TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` + ID ResourceControlID `json:"Id"` + ResourceID string `json:"ResourceId"` + SubResourceIDs []string `json:"SubResourceIds"` + Type ResourceControlType `json:"Type"` + AdministratorsOnly bool `json:"AdministratorsOnly"` + UserAccesses []UserResourceAccess `json:"UserAccesses"` + TeamAccesses []TeamResourceAccess `json:"TeamAccesses"` // Deprecated fields - // Deprecated: OwnerID field is deprecated in DBVersion == 2 - OwnerID UserID `json:"OwnerId"` - // Deprecated: AccessLevel field is deprecated in DBVersion == 2 - AccessLevel ResourceAccessLevel `json:"AccessLevel"` + // Deprecated in DBVersion == 2 + OwnerID UserID `json:"OwnerId,omitempty"` + AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` } // ResourceControlType represents the type of resource associated to the resource control (volume, container, service). @@ -325,6 +327,7 @@ type ( FileService interface { StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) + DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFiles(folder string) error } @@ -342,9 +345,9 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.14.0" + APIVersion = "1.14.1" // DBVersion is the version number of the Portainer database. - DBVersion = 3 + DBVersion = 4 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" ) @@ -396,4 +399,8 @@ const ( ServiceResourceControl // VolumeResourceControl represents a resource control associated to a Docker volume VolumeResourceControl + // NetworkResourceControl represents a resource control associated to a Docker network + NetworkResourceControl + // SecretResourceControl represents a resource control associated to a Docker secret + SecretResourceControl ) diff --git a/api/swagger.yaml b/api/swagger.yaml index aa7713195..255a62949 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1,27 +1,62 @@ --- swagger: "2.0" info: - description: "Portainer API is an HTTP API served by Portainer. It is used by the\ - \ Portainer UI and everything you can do with the UI can be done using the HTTP\ - \ API.\nYou can find out more about Portainer at [http://portainer.io](http://portainer.io)\ - \ and get some support on [Slack](http://portainer.io/slack/).\n\n# Authentication\n\ - \nMost of the API endpoints require to be authenticated as well as some level\ - \ of authorization to be used.\nPortainer API uses JSON Web Token to manage authentication\ - \ and thus requires you to provide a token in the **Authorization** header of\ - \ each request\nwith the **Bearer** authentication mechanism.\n\nExample:\n```\n\ - Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE\n\ - ```\n\n# Security\n\nEach API endpoint has an associated access policy, it is\ - \ documented in the description of each endpoint.\n\nDifferent access policies\ - \ are available:\n* Public access\n* Authenticated access\n* Restricted access\n\ - * Administrator access\n\n### Public access\n\nNo authentication is required to\ - \ access the endpoints with this access policy.\n\n### Authenticated access\n\n\ - Authentication is required to access the endpoints with this access policy.\n\n\ - ### Restricted access\n\nAuthentication is required to access the endpoints with\ - \ this access policy.\nExtra-checks might be added to ensure access to the resource\ - \ is granted. Returned data might also be filtered.\n\n### Administrator access\n\ - \nAuthentication as well as an administrator role are required to access the endpoints\ - \ with this access policy.\n" - version: "1.14.0" + description: | + Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API. + Examples are available at https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8 + You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). + + # Authentication + + Most of the API endpoints require to be authenticated as well as some level of authorization to be used. + Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request + with the **Bearer** authentication mechanism. + + Example: + ``` + Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE + ``` + + # Security + + Each API endpoint has an associated access policy, it is documented in the description of each endpoint. + + Different access policies are available: + * Public access + * Authenticated access + * Restricted access + * Administrator access + + ### Public access + + No authentication is required to access the endpoints with this access policy. + + ### Authenticated access + + Authentication is required to access the endpoints with this access policy. + + ### Restricted access + + Authentication is required to access the endpoints with this access policy. + Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered. + + ### Administrator access + + Authentication as well as an administrator role are required to access the endpoints with this access policy. + + # Execute Docker requests + + Portainer **DO NOT** expose specific endpoints to manage your Docker resources (create a container, remove a volume, etc...). + + Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API. + + To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This + endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the + Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). + + **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.14.1" title: "Portainer API" contact: email: "info@portainer.io" @@ -63,8 +98,9 @@ paths: tags: - "auth" summary: "Authenticate a user" - description: "Use this endpoint to authenticate against Portainer using a username\ - \ and password. \n**Access policy**: public\n" + description: | + Use this endpoint to authenticate against Portainer using a username and password. + **Access policy**: public operationId: "AuthenticateUser" consumes: - "application/json" @@ -105,8 +141,9 @@ paths: tags: - "dockerhub" summary: "Retrieve DockerHub information" - description: "Use this endpoint to retrieve the information used to connect\ - \ to the DockerHub \n**Access policy**: authenticated\n" + description: | + Use this endpoint to retrieve the information used to connect to the DockerHub + **Access policy**: authenticated operationId: "DockerHubInspect" produces: - "application/json" @@ -124,8 +161,9 @@ paths: tags: - "dockerhub" summary: "Update DockerHub information" - description: "Use this endpoint to update the information used to connect to\ - \ the DockerHub \n**Access policy**: administrator\n" + description: | + Use this endpoint to update the information used to connect to the DockerHub + **Access policy**: administrator operationId: "DockerHubUpdate" consumes: - "application/json" @@ -157,9 +195,11 @@ paths: tags: - "endpoints" summary: "List endpoints" - description: "List all endpoints based on the current user authorizations. Will\n\ - return all endpoints if using an administrator account otherwise it will\n\ - only return authorized endpoints. \n**Access policy**: restricted \n" + description: | + List all endpoints based on the current user authorizations. Will + return all endpoints if using an administrator account otherwise it will + only return authorized endpoints. + **Access policy**: restricted operationId: "EndpointList" produces: - "application/json" @@ -177,8 +217,9 @@ paths: tags: - "endpoints" summary: "Create a new endpoint" - description: "Create a new endpoint that will be used to manage a Docker environment.\ - \ \n**Access policy**: administrator\n" + description: | + Create a new endpoint that will be used to manage a Docker environment. + **Access policy**: administrator operationId: "EndpointCreate" consumes: - "application/json" @@ -219,8 +260,9 @@ paths: tags: - "endpoints" summary: "Inspect an endpoint" - description: "Retrieve details abount an endpoint. \n**Access policy**: administrator\ - \ \n" + description: | + Retrieve details abount an endpoint. + **Access policy**: administrator operationId: "EndpointInspect" produces: - "application/json" @@ -257,7 +299,9 @@ paths: tags: - "endpoints" summary: "Update an endpoint" - description: "Update an endpoint. \n**Access policy**: administrator\n" + description: | + Update an endpoint. + **Access policy**: administrator operationId: "EndpointUpdate" consumes: - "application/json" @@ -307,7 +351,9 @@ paths: tags: - "endpoints" summary: "Remove an endpoint" - description: "Remove an endpoint. \n**Access policy**: administrator \n" + description: | + Remove an endpoint. + **Access policy**: administrator operationId: "EndpointDelete" parameters: - name: "id" @@ -348,8 +394,9 @@ paths: tags: - "endpoints" summary: "Manage accesses to an endpoint" - description: "Manage user and team accesses to an endpoint. \n**Access policy**:\ - \ administrator \n" + description: | + Manage user and team accesses to an endpoint. + **Access policy**: administrator operationId: "EndpointAccessUpdate" consumes: - "application/json" @@ -388,15 +435,17 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /registries: get: tags: - "registries" summary: "List registries" - description: "List all registries based on the current user authorizations.\n\ - Will return all registries if using an administrator account otherwise it\n\ - will only return authorized registries. \n**Access policy**: restricted \ - \ \n" + description: | + List all registries based on the current user authorizations. + Will return all registries if using an administrator account otherwise it + will only return authorized registries. + **Access policy**: restricted operationId: "RegistryList" produces: - "application/json" @@ -414,8 +463,9 @@ paths: tags: - "registries" summary: "Create a new registry" - description: "Create a new registry. \n**Access policy**: administrator \ - \ \n" + description: | + Create a new registry. + **Access policy**: administrator operationId: "RegistryCreate" consumes: - "application/json" @@ -456,8 +506,9 @@ paths: tags: - "registries" summary: "Inspect a registry" - description: "Retrieve details about a registry. \n**Access policy**: administrator\ - \ \n" + description: | + Retrieve details about a registry. + **Access policy**: administrator operationId: "RegistryInspect" produces: - "application/json" @@ -494,7 +545,9 @@ paths: tags: - "registries" summary: "Update a registry" - description: "Update a registry. \n**Access policy**: administrator \n" + description: | + Update a registry. + **Access policy**: administrator operationId: "RegistryUpdate" consumes: - "application/json" @@ -551,8 +604,9 @@ paths: tags: - "registries" summary: "Remove a registry" - description: "Remove a registry. \n**Access policy**: administrator \ - \ \n" + description: | + Remove a registry. + **Access policy**: administrator operationId: "RegistryDelete" parameters: - name: "id" @@ -586,8 +640,9 @@ paths: tags: - "registries" summary: "Manage accesses to a registry" - description: "Manage user and team accesses to a registry. \n**Access policy**:\ - \ administrator \n" + description: | + Manage user and team accesses to a registry. + **Access policy**: administrator operationId: "RegistryAccessUpdate" consumes: - "application/json" @@ -631,8 +686,9 @@ paths: tags: - "resource_controls" summary: "Create a new resource control" - description: "Create a new resource control to restrict access to a Docker resource.\ - \ \n**Access policy**: restricted \n" + description: | + Create a new resource control to restrict access to a Docker resource. + **Access policy**: restricted operationId: "ResourceControlCreate" consumes: - "application/json" @@ -678,8 +734,9 @@ paths: tags: - "resource_controls" summary: "Update a resource control" - description: "Update a resource control. \n**Access policy**: restricted \ - \ \n" + description: | + Update a resource control. + **Access policy**: restricted operationId: "ResourceControlUpdate" consumes: - "application/json" @@ -729,8 +786,9 @@ paths: tags: - "resource_controls" summary: "Remove a resource control" - description: "Remove a resource control. \n**Access policy**: restricted \ - \ \n" + description: | + Remove a resource control. + **Access policy**: restricted operationId: "ResourceControlDelete" parameters: - name: "id" @@ -771,8 +829,9 @@ paths: tags: - "settings" summary: "Retrieve Portainer settings" - description: "Retrieve Portainer settings. \n**Access policy**: administrator\ - \ \n" + description: | + Retrieve Portainer settings. + **Access policy**: administrator operationId: "SettingsInspect" produces: - "application/json" @@ -790,8 +849,9 @@ paths: tags: - "settings" summary: "Update Portainer settings" - description: "Update Portainer settings. \n**Access policy**: administrator\ - \ \n" + description: | + Update Portainer settings. + **Access policy**: administrator operationId: "SettingsUpdate" consumes: - "application/json" @@ -823,9 +883,9 @@ paths: tags: - "settings" summary: "Retrieve Portainer public settings" - description: "Retrieve public settings. Returns a small set of settings that\ - \ are not reserved to administrators only. \n**Access policy**: public \ - \ \n" + description: | + Retrieve public settings. Returns a small set of settings that are not reserved to administrators only. + **Access policy**: public operationId: "PublicSettingsInspect" produces: - "application/json" @@ -844,8 +904,9 @@ paths: tags: - "settings" summary: "Test LDAP connectivity" - description: "Test LDAP connectivity using LDAP details. \n**Access policy**:\ - \ administrator \n" + description: | + Test LDAP connectivity using LDAP details. + **Access policy**: administrator operationId: "SettingsLDAPCheck" consumes: - "application/json" @@ -877,8 +938,9 @@ paths: tags: - "status" summary: "Check Portainer status" - description: "Retrieve Portainer status. \n**Access policy**: public \ - \ \n" + description: | + Retrieve Portainer status. + **Access policy**: public operationId: "StatusInspect" produces: - "application/json" @@ -897,9 +959,9 @@ paths: tags: - "users" summary: "List users" - description: "List Portainer users. Non-administrator users will only be able\ - \ to list other non-administrator user accounts. \n**Access policy**: restricted\ - \ \n" + description: | + List Portainer users. Non-administrator users will only be able to list other non-administrator user accounts. + **Access policy**: restricted operationId: "UserList" produces: - "application/json" @@ -917,9 +979,10 @@ paths: tags: - "users" summary: "Create a new user" - description: "Create a new Portainer user. Only team leaders and administrators\ - \ can create users. Only administrators can\ncreate an administrator user\ - \ account. \n**Access policy**: restricted \n" + description: | + Create a new Portainer user. Only team leaders and administrators can create users. Only administrators can + create an administrator user account. + **Access policy**: restricted operationId: "UserCreate" consumes: - "application/json" @@ -967,8 +1030,9 @@ paths: tags: - "users" summary: "Inspect a user" - description: "Retrieve details about a user. \n**Access policy**: administrator\ - \ \n" + description: | + Retrieve details about a user. + **Access policy**: administrator operationId: "UserInspect" produces: - "application/json" @@ -1005,8 +1069,9 @@ paths: tags: - "users" summary: "Update a user" - description: "Update user details. A regular user account can only update his\ - \ details. \n**Access policy**: authenticated \n" + description: | + Update user details. A regular user account can only update his details. + **Access policy**: authenticated operationId: "UserUpdate" consumes: - "application/json" @@ -1056,7 +1121,9 @@ paths: tags: - "users" summary: "Remove a user" - description: "Remove a user. \n**Access policy**: administrator \n" + description: | + Remove a user. + **Access policy**: administrator operationId: "UserDelete" parameters: - name: "id" @@ -1090,8 +1157,9 @@ paths: tags: - "users" summary: "Inspect a user memberships" - description: "Inspect a user memberships. \n**Access policy**: authenticated\ - \ \n" + description: | + Inspect a user memberships. + **Access policy**: authenticated operationId: "UserMembershipsInspect" produces: - "application/json" @@ -1124,13 +1192,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /users/{id}/passwd: post: tags: - "users" summary: "Check password validity for a user" - description: "Check if the submitted password is valid for the specified user.\ - \ \n**Access policy**: authenticated \n" + description: | + Check if the submitted password is valid for the specified user. + **Access policy**: authenticated operationId: "UserPasswordCheck" consumes: - "application/json" @@ -1171,13 +1241,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /users/admin/check: get: tags: - "users" summary: "Check administrator account existence" - description: "Check if an administrator account exists in the database.\n**Access\ - \ policy**: public \n" + description: | + Check if an administrator account exists in the database. + **Access policy**: public operationId: "UserAdminCheck" produces: - "application/json" @@ -1198,13 +1270,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /users/admin/init: post: tags: - "users" summary: "Initialize administrator account" - description: "Initialize the 'admin' user account.\n**Access policy**: public\ - \ \n" + description: | + Initialize the 'admin' user account. + **Access policy**: public operationId: "UserAdminInit" consumes: - "application/json" @@ -1238,34 +1312,35 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /upload/tls/{certificate}: post: tags: - "upload" summary: "Upload TLS files" - description: "Use this endpoint to upload TLS files. \n**Access policy**: administrator\n" + description: | + Use this endpoint to upload TLS files. + **Access policy**: administrator operationId: "UploadTLS" consumes: - - "multipart/form-data" + - multipart/form-data produces: - "application/json" parameters: - - name: "certificate" - in: "path" + - in: "path" + name: "certificate" description: "TLS file type. Valid values are 'ca', 'cert' or 'key'." required: true type: "string" - - name: "folder" - in: "query" - description: "Folder where the TLS file will be stored. Will be created if\ - \ not existing." + - in: "query" + name: "folder" + description: "Folder where the TLS file will be stored. Will be created if not existing." required: true type: "string" - - name: "file" - in: "formData" - description: "The file to upload." - required: false + - in: "formData" + name: "file" type: "file" + description: "The file to upload." responses: 200: description: "Success" @@ -1280,13 +1355,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /teams: get: tags: - "teams" summary: "List teams" - description: "List teams. For non-administrator users, will only list the teams\ - \ they are member of. \n**Access policy**: restricted \n" + description: | + List teams. For non-administrator users, will only list the teams they are member of. + **Access policy**: restricted operationId: "TeamList" produces: - "application/json" @@ -1304,8 +1381,9 @@ paths: tags: - "teams" summary: "Create a new team" - description: "Create a new team. \n**Access policy**: administrator \ - \ \n" + description: | + Create a new team. + **Access policy**: administrator operationId: "TeamCreate" consumes: - "application/json" @@ -1353,8 +1431,9 @@ paths: tags: - "teams" summary: "Inspect a team" - description: "Retrieve details about a team. Access is only available for administrator\ - \ and leaders of that team. \n**Access policy**: restricted \n" + description: | + Retrieve details about a team. Access is only available for administrator and leaders of that team. + **Access policy**: restricted operationId: "TeamInspect" produces: - "application/json" @@ -1398,8 +1477,9 @@ paths: tags: - "teams" summary: "Update a team" - description: "Update a team. \n**Access policy**: administrator \ - \ \n" + description: | + Update a team. + **Access policy**: administrator operationId: "TeamUpdate" consumes: - "application/json" @@ -1442,7 +1522,9 @@ paths: tags: - "teams" summary: "Remove a team" - description: "Remove a team. \n**Access policy**: administrator \n" + description: | + Remove a team. + **Access policy**: administrator operationId: "TeamDelete" parameters: - name: "id" @@ -1471,13 +1553,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /teams/{id}/memberships: get: tags: - "teams" summary: "Inspect a team memberships" - description: "Inspect a team memberships. Access is only available for administrator\ - \ and leaders of that team. \n**Access policy**: restricted \n" + description: | + Inspect a team memberships. Access is only available for administrator and leaders of that team. + **Access policy**: restricted operationId: "TeamMembershipsInspect" produces: - "application/json" @@ -1510,13 +1594,15 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" + /team_memberships: get: tags: - "team_memberships" summary: "List team memberships" - description: "List team memberships. Access is only available to administrators\ - \ and team leaders. \n**Access policy**: restricted \n" + description: | + List team memberships. Access is only available to administrators and team leaders. + **Access policy**: restricted operationId: "TeamMembershipList" produces: - "application/json" @@ -1541,8 +1627,9 @@ paths: tags: - "team_memberships" summary: "Create a new team membership" - description: "Create a new team memberships. Access is only available to administrators\ - \ leaders of the associated team. \n**Access policy**: restricted \n" + description: | + Create a new team memberships. Access is only available to administrators leaders of the associated team. + **Access policy**: restricted operationId: "TeamMembershipCreate" consumes: - "application/json" @@ -1590,9 +1677,9 @@ paths: tags: - "team_memberships" summary: "Update a team membership" - description: "Update a team membership. Access is only available to administrators\ - \ leaders of the associated team. \n**Access policy**: restricted \ - \ \n" + description: | + Update a team membership. Access is only available to administrators leaders of the associated team. + **Access policy**: restricted operationId: "TeamMembershipUpdate" consumes: - "application/json" @@ -1642,8 +1729,9 @@ paths: tags: - "team_memberships" summary: "Remove a team membership" - description: "Remove a team membership. Access is only available to administrators\ - \ leaders of the associated team. \n**Access policy**: restricted \n" + description: | + Remove a team membership. Access is only available to administrators leaders of the associated team. + **Access policy**: restricted operationId: "TeamMembershipDelete" parameters: - name: "id" @@ -1684,17 +1772,18 @@ paths: tags: - "templates" summary: "Retrieve App templates" - description: "Retrieve App templates. \nYou can find more information about\ - \ the format at http://portainer.readthedocs.io/en/stable/templates.html \ - \ \n**Access policy**: authenticated \n" + description: | + Retrieve App templates. + You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html + **Access policy**: authenticated operationId: "TemplateList" produces: - "application/json" parameters: - name: "key" in: "query" - description: "Templates key. Valid values are 'container' or 'linuxserver.io'." required: true + description: "Templates key. Valid values are 'container' or 'linuxserver.io'." type: "string" responses: 200: @@ -1780,7 +1869,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.14.0" + example: "1.14.1" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -1799,8 +1888,8 @@ definitions: AuthenticationMethod: type: "integer" example: 1 - description: "Active authentication method for the Portainer instance. Valid\ - \ values are: 1 for managed or 2 for LDAP." + description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." + TLSConfiguration: type: "object" properties: @@ -1824,14 +1913,14 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" + LDAPSearchSettings: type: "object" properties: BaseDN: type: "string" example: "dc=ldap,dc=domain,dc=tld" - description: "The distinguished name of the element from which the LDAP server\ - \ will search for users" + description: "The distinguished name of the element from which the LDAP server will search for users" Filter: type: "string" example: "(objectClass=account)" @@ -1840,6 +1929,7 @@ definitions: type: "string" example: "uid" description: "LDAP attribute which denotes the username" + LDAPSettings: type: "object" properties: @@ -1865,6 +1955,7 @@ definitions: type: "array" items: $ref: "#/definitions/LDAPSearchSettings" + Settings: type: "object" properties: @@ -1893,8 +1984,7 @@ definitions: AuthenticationMethod: type: "integer" example: 1 - description: "Active authentication method for the Portainer instance. Valid\ - \ values are: 1 for managed or 2 for LDAP." + description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." LDAPSettings: $ref: "#/definitions/LDAPSettings" Settings_BlackListedLabels: @@ -2060,6 +2150,14 @@ definitions: type: "boolean" example: true description: "Require TLS to connect against this endpoint" + TLSSkipVerify: + type: "boolean" + example: false + description: "Skip server verification when using TLS" + TLSSkipClientVerify: + type: "boolean" + example: false + description: "Skip client verification when using TLS" EndpointCreateResponse: type: "object" properties: @@ -2091,6 +2189,14 @@ definitions: type: "boolean" example: true description: "Require TLS to connect against this endpoint" + TLSSkipVerify: + type: "boolean" + example: false + description: "Skip server verification when using TLS" + TLSSkipClientVerify: + type: "boolean" + example: false + description: "Skip client verification when using TLS" EndpointAccessUpdateRequest: type: "object" properties: @@ -2257,8 +2363,8 @@ definitions: SettingsUpdateRequest: type: "object" required: - - "AuthenticationMethod" - "TemplatesURL" + - "AuthenticationMethod" properties: TemplatesURL: type: "string" @@ -2285,8 +2391,7 @@ definitions: AuthenticationMethod: type: "integer" example: 1 - description: "Active authentication method for the Portainer instance. Valid\ - \ values are: 1 for managed or 2 for LDAP." + description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP." LDAPSettings: $ref: "#/definitions/LDAPSettings" UserCreateRequest: @@ -2383,12 +2488,13 @@ definitions: type: "array" items: $ref: "#/definitions/TeamMembership" + TeamMembershipCreateRequest: type: "object" required: - - "Role" - - "TeamID" - "UserID" + - "TeamID" + - "Role" properties: UserID: type: "integer" @@ -2401,8 +2507,7 @@ definitions: Role: type: "integer" example: 1 - description: "Role for the user inside the team (1 for leader and 2 for regular\ - \ member)" + description: "Role for the user inside the team (1 for leader and 2 for regular member)" TeamMembershipCreateResponse: type: "object" properties: @@ -2417,9 +2522,9 @@ definitions: TeamMembershipUpdateRequest: type: "object" required: - - "Role" - - "TeamID" - "UserID" + - "TeamID" + - "Role" properties: UserID: type: "integer" @@ -2432,8 +2537,7 @@ definitions: Role: type: "integer" example: 1 - description: "Role for the user inside the team (1 for leader and 2 for regular\ - \ member)" + description: "Role for the user inside the team (1 for leader and 2 for regular member)" SettingsLDAPCheckRequest: type: "object" properties: diff --git a/app/app.js b/app/app.js index d7774719f..02754f6c8 100644 --- a/app/app.js +++ b/app/app.js @@ -23,6 +23,7 @@ angular.module('portainer', [ 'container', 'containerConsole', 'containerLogs', + 'containerStats', 'serviceLogs', 'containers', 'createContainer', @@ -31,14 +32,15 @@ angular.module('portainer', [ 'createSecret', 'createService', 'createVolume', - 'docker', + 'engine', 'endpoint', 'endpointAccess', - 'endpointInit', 'endpoints', 'events', 'image', 'images', + 'initAdmin', + 'initEndpoint', 'main', 'network', 'networks', @@ -53,8 +55,8 @@ angular.module('portainer', [ 'settings', 'settingsAuthentication', 'sidebar', - 'stats', 'swarm', + 'swarmVisualizer', 'task', 'team', 'teams', @@ -63,7 +65,8 @@ angular.module('portainer', [ 'users', 'userSettings', 'volume', - 'volumes']) + 'volumes', + 'rzModule']) .config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) { 'use strict'; @@ -157,8 +160,8 @@ angular.module('portainer', [ url: '^/containers/:id/stats', views: { 'content@': { - templateUrl: 'app/components/stats/stats.html', - controller: 'StatsController' + templateUrl: 'app/components/containerStats/containerStats.html', + controller: 'ContainerStatsController' }, 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', @@ -321,12 +324,39 @@ angular.module('portainer', [ } } }) - .state('docker', { - url: '/docker/', + .state('init', { + abstract: true, + url: '/init', views: { 'content@': { - templateUrl: 'app/components/docker/docker.html', - controller: 'DockerController' + template: '
' + } + } + }) + .state('init.endpoint', { + url: '/endpoint', + views: { + 'content@': { + templateUrl: 'app/components/initEndpoint/initEndpoint.html', + controller: 'InitEndpointController' + } + } + }) + .state('init.admin', { + url: '/admin', + views: { + 'content@': { + templateUrl: 'app/components/initAdmin/initAdmin.html', + controller: 'InitAdminController' + } + } + }) + .state('engine', { + url: '/engine/', + views: { + 'content@': { + templateUrl: 'app/components/engine/engine.html', + controller: 'EngineController' }, 'sidebar@': { templateUrl: 'app/components/sidebar/sidebar.html', @@ -373,15 +403,6 @@ angular.module('portainer', [ } } }) - .state('endpointInit', { - url: '/init/endpoint', - views: { - 'content@': { - templateUrl: 'app/components/endpointInit/endpointInit.html', - controller: 'EndpointInitController' - } - } - }) .state('events', { url: '/events/', views: { @@ -716,7 +737,7 @@ angular.module('portainer', [ } }) .state('swarm', { - url: '/swarm/', + url: '/swarm', views: { 'content@': { templateUrl: 'app/components/swarm/swarm.html', @@ -727,7 +748,21 @@ angular.module('portainer', [ controller: 'SidebarController' } } - }); + }) + .state('swarm.visualizer', { + url: '/visualizer', + views: { + 'content@': { + templateUrl: 'app/components/swarmVisualizer/swarmVisualizer.html', + controller: 'SwarmVisualizerController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + ; }]) .run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) { EndpointProvider.initialize(); diff --git a/app/components/auth/auth.html b/app/components/auth/auth.html index 8668d328a..f58276395 100644 --- a/app/components/auth/auth.html +++ b/app/components/auth/auth.html @@ -1,92 +1,38 @@
-
+
- +
- -
-
- - - -
-
- -
+
- + +
+
+ + + +
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+
+ + +
+ Items per page: + +
+
+ + + + + + + + + + + + + + + + + + +
+ + {{ title }} + + + +
{{ procInfo }}
Loading...
No processes available.
+
+ +
+
+
+
+
diff --git a/app/components/containerStats/containerStatsController.js b/app/components/containerStats/containerStatsController.js new file mode 100644 index 000000000..a48383507 --- /dev/null +++ b/app/components/containerStats/containerStatsController.js @@ -0,0 +1,159 @@ +angular.module('containerStats', []) +.controller('ContainerStatsController', ['$q', '$scope', '$stateParams', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'Pagination', +function ($q, $scope, $stateParams, $document, $interval, ContainerService, ChartService, Notifications, Pagination) { + + $scope.state = { + refreshRate: '5' + }; + + $scope.state.pagination_count = Pagination.getPaginationCount('stats_processes'); + $scope.sortType = 'CMD'; + $scope.sortReverse = false; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.changePaginationCount = function() { + Pagination.setPaginationCount('stats_processes', $scope.state.pagination_count); + }; + + $scope.$on('$destroy', function() { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + repeater = null; + } + } + + function updateNetworkChart(stats, chart) { + var rx = stats.Networks[0].rx_bytes; + var tx = stats.Networks[0].tx_bytes; + var label = moment(stats.Date).format('HH:mm:ss'); + + ChartService.UpdateNetworkChart(label, rx, tx, chart); + } + + function updateMemoryChart(stats, chart) { + var label = moment(stats.Date).format('HH:mm:ss'); + var value = stats.MemoryUsage; + + ChartService.UpdateMemoryChart(label, value, chart); + } + + function updateCPUChart(stats, chart) { + var label = moment(stats.Date).format('HH:mm:ss'); + var value = calculateCPUPercentUnix(stats); + + ChartService.UpdateCPUChart(label, value, chart); + } + + function calculateCPUPercentUnix(stats) { + var cpuPercent = 0.0; + var cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage; + var systemDelta = stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage; + + if (systemDelta > 0.0 && cpuDelta > 0.0) { + cpuPercent = (cpuDelta / systemDelta) * stats.CPUCores * 100.0; + } + + return cpuPercent; + } + + $scope.changeUpdateRepeater = function() { + var networkChart = $scope.networkChart; + var cpuChart = $scope.cpuChart; + var memoryChart = $scope.memoryChart; + + stopRepeater(); + setUpdateRepeater(networkChart, cpuChart, memoryChart); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(1500); + }; + + function startChartUpdate(networkChart, cpuChart, memoryChart) { + $('#loadingViewSpinner').show(); + $q.all({ + stats: ContainerService.containerStats($stateParams.id), + top: ContainerService.containerTop($stateParams.id) + }) + .then(function success(data) { + var stats = data.stats; + $scope.processInfo = data.top; + updateNetworkChart(stats, networkChart); + updateMemoryChart(stats, memoryChart); + updateCPUChart(stats, cpuChart); + setUpdateRepeater(networkChart, cpuChart, memoryChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container statistics'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + function setUpdateRepeater(networkChart, cpuChart, memoryChart) { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function() { + $q.all({ + stats: ContainerService.containerStats($stateParams.id), + top: ContainerService.containerTop($stateParams.id) + }) + .then(function success(data) { + var stats = data.stats; + $scope.processInfo = data.top; + updateNetworkChart(stats, networkChart); + updateMemoryChart(stats, memoryChart); + updateCPUChart(stats, cpuChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve container statistics'); + }); + }, refreshRate * 1000); + } + + function initCharts() { + var networkChartCtx = $('#networkChart'); + var networkChart = ChartService.CreateNetworkChart(networkChartCtx); + $scope.networkChart = networkChart; + + var cpuChartCtx = $('#cpuChart'); + var cpuChart = ChartService.CreateCPUChart(cpuChartCtx); + $scope.cpuChart = cpuChart; + + var memoryChartCtx = $('#memoryChart'); + var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx); + $scope.memoryChart = memoryChart; + + startChartUpdate(networkChart, cpuChart, memoryChart); + } + + function initView() { + $('#loadingViewSpinner').show(); + + ContainerService.container($stateParams.id) + .then(function success(data) { + $scope.container = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container information'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + + $document.ready(function() { + initCharts(); + }); + } + + initView(); +}]); diff --git a/app/components/containers/containers.html b/app/components/containers/containers.html index 3cd1a3aee..9ad0faf61 100644 --- a/app/components/containers/containers.html +++ b/app/components/containers/containers.html @@ -61,6 +61,9 @@ + + + @@ -106,8 +109,8 @@ {{ container.Status }} {{ container.Status }} - {{ container|swarmcontainername|truncate: 40}} - {{ container|containername|truncate: 40}} + {{ container|swarmcontainername|truncate: truncate_size}} + {{ container|containername|truncate: truncate_size}} {{ container.Image | hideshasum }} {{ container.IP ? container.IP : '-' }} {{ container.hostIP }} diff --git a/app/components/containers/containersController.js b/app/components/containers/containersController.js index 82dd10b64..4f9ac54b3 100644 --- a/app/components/containers/containersController.js +++ b/app/components/containers/containersController.js @@ -1,13 +1,16 @@ angular.module('containers', []) - .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', - function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) { + .controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', 'LocalStorage', + function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider, LocalStorage) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('containers'); - $scope.state.displayAll = true; + $scope.state.displayAll = LocalStorage.getFilterContainerShowAll(); $scope.state.displayIP = false; $scope.sortType = 'State'; $scope.sortReverse = false; $scope.state.selectedItemCount = 0; + $scope.truncate_size = 40; + $scope.showMore = true; + $scope.order = function (sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; @@ -130,6 +133,7 @@ angular.module('containers', []) }; $scope.toggleGetAll = function () { + LocalStorage.storeFilterContainerShowAll($scope.state.displayAll); update({all: $scope.state.displayAll ? 1 : 0}); }; @@ -161,6 +165,12 @@ angular.module('containers', []) batch($scope.containers, Container.remove, 'Removed'); }; + + $scope.truncateMore = function(size) { + $scope.truncate_size = 80; + $scope.showMore = false; + }; + $scope.confirmRemoveAction = function () { var isOneContainerRunning = false; angular.forEach($scope.containers, function (c) { @@ -205,7 +215,7 @@ angular.module('containers', []) if(container.Status === 'paused') { $scope.state.noPausedItemsSelected = false; - } else if(container.Status === 'stopped' || + } else if(container.Status === 'stopped' || container.Status === 'created') { $scope.state.noStoppedItemsSelected = false; } else if(container.Status === 'running') { diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index ecc556182..5997747f7 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -403,7 +403,6 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, } function loadFromContainerImageConfig(d) { - // If no registry found, we let default DockerHub and let full image path var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image); RegistryService.retrieveRegistryFromRepository($scope.config.Image) .then(function success(data) { diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index fdae6b4de..fd282d5b8 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -21,24 +21,30 @@
Image configuration
- -
- +
+ + The Docker registry for the {{ config.Image }} image is not registered inside Portainer, you will not be able to create a container. Please register that registry first.
- - -
-
- - +
+ +
+
+ + +
+
+ + +
+
+
-
Ports configuration
@@ -106,7 +112,7 @@
- + Cancel {{ state.formValidationError }} diff --git a/app/components/createNetwork/createNetworkController.js b/app/components/createNetwork/createNetworkController.js index 01b0b3c7a..dc804b7a3 100644 --- a/app/components/createNetwork/createNetworkController.js +++ b/app/components/createNetwork/createNetworkController.js @@ -1,13 +1,21 @@ angular.module('createNetwork', []) -.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', 'LabelHelper', -function ($scope, $state, Notifications, Network, LabelHelper) { +.controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'NetworkService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', +function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator) { + $scope.formValues = { DriverOptions: [], Subnet: '', Gateway: '', - Labels: [] + Labels: [], + AccessControlData: new AccessControlFormData() }; + $scope.state = { + formValidationError: '' + }; + + $scope.availableNetworkDrivers = []; + $scope.config = { Driver: 'bridge', CheckDuplicate: true, @@ -37,23 +45,6 @@ function ($scope, $state, Notifications, Network, LabelHelper) { $scope.formValues.Labels.splice(index, 1); }; - function createNetwork(config) { - $('#createNetworkSpinner').show(); - Network.create(config, function (d) { - if (d.message) { - $('#createNetworkSpinner').hide(); - Notifications.error('Unable to create network', {}, d.message); - } else { - Notifications.success('Network created', d.Id); - $('#createNetworkSpinner').hide(); - $state.go('networks', {}, {reload: true}); - } - }, function (e) { - $('#createNetworkSpinner').hide(); - Notifications.error('Failure', e, 'Unable to create network'); - }); - } - function prepareIPAMConfiguration(config) { if ($scope.formValues.Subnet) { var ipamConfig = {}; @@ -85,8 +76,66 @@ function ($scope, $state, Notifications, Network, LabelHelper) { return config; } + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + $scope.create = function () { - var config = prepareConfiguration(); - createNetwork(config); + $('#createResourceSpinner').show(); + + var networkConfiguration = prepareConfiguration(); + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + NetworkService.create(networkConfiguration) + .then(function success(data) { + var networkIdentifier = data.Id; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('network', networkIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Network successfully created'); + $state.go('networks', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occured during network creation'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); }; + + function initView() { + $('#loadingViewSpinner').show(); + var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + if(endpointProvider !== 'DOCKER_SWARM') { + PluginService.networkPlugins(apiVersion < 1.25) + .then(function success(data){ + $scope.availableNetworkDrivers = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve network drivers'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + } + + initView(); }]); diff --git a/app/components/createNetwork/createnetwork.html b/app/components/createNetwork/createnetwork.html index a9f1ef7d4..4d5636a10 100644 --- a/app/components/createNetwork/createnetwork.html +++ b/app/components/createNetwork/createnetwork.html @@ -1,5 +1,7 @@ - + + + Networks > Add network @@ -39,8 +41,11 @@
-
- +
+ +
@@ -116,6 +121,9 @@
+ + +
Actions @@ -124,7 +132,8 @@
Cancel - + + {{ state.formValidationError }}
diff --git a/app/components/createSecret/createSecretController.js b/app/components/createSecret/createSecretController.js index 3f2533270..ce94e676b 100644 --- a/app/components/createSecret/createSecretController.js +++ b/app/components/createSecret/createSecretController.js @@ -1,11 +1,17 @@ angular.module('createSecret', []) -.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', -function ($scope, $state, Notifications, SecretService, LabelHelper) { +.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator', +function ($scope, $state, Notifications, SecretService, LabelHelper, Authentication, ResourceControlService, FormValidator) { + $scope.formValues = { Name: '', Data: '', Labels: [], - encodeSecret: true + encodeSecret: true, + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + formValidationError: '' }; $scope.addLabel = function() { @@ -36,10 +42,38 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) { return config; } - function createSecret(config) { - $('#createSecretSpinner').show(); - SecretService.create(config) + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + $scope.create = function () { + $('#createResourceSpinner').show(); + + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + var secretConfiguration = prepareConfiguration(); + SecretService.create(secretConfiguration) .then(function success(data) { + var secretIdentifier = data.ID; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('secret', secretIdentifier, userId, accessControlData, []); + }) + .then(function success() { Notifications.success('Secret successfully created'); $state.go('secrets', {}, {reload: true}); }) @@ -47,12 +81,7 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) { Notifications.error('Failure', err, 'Unable to create secret'); }) .finally(function final() { - $('#createSecretSpinner').hide(); + $('#createResourceSpinner').hide(); }); - } - - $scope.create = function () { - var config = prepareConfiguration(); - createSecret(config); }; }]); diff --git a/app/components/createSecret/createsecret.html b/app/components/createSecret/createsecret.html index c918e8cd5..dbf4efe06 100644 --- a/app/components/createSecret/createsecret.html +++ b/app/components/createSecret/createsecret.html @@ -66,6 +66,9 @@
+ + +
Actions @@ -74,7 +77,8 @@
Cancel - + + {{ state.formValidationError }}
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index d7723503d..c521d0aae 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,8 +1,8 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$q', '$scope', '$state', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', -function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper) { +.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', +function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService) { $scope.formValues = { Name: '', @@ -28,13 +28,25 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic UpdateOrder: 'stop-first', FailureAction: 'pause', Secrets: [], - AccessControlData: new AccessControlFormData() + AccessControlData: new AccessControlFormData(), + CpuLimit: 0, + CpuReservation: 0, + MemoryLimit: 0, + MemoryReservation: 0, + MemoryLimitUnit: 'MB', + MemoryReservationUnit: 'MB' }; $scope.state = { formValidationError: '' }; + $scope.refreshSlider = function () { + $timeout(function () { + $scope.$broadcast('rzSliderForceRender'); + }); + }; + $scope.addPortBinding = function() { $scope.formValues.Ports.push({ PublishedPort: '', TargetPort: '', Protocol: 'tcp', PublishMode: 'ingress' }); }; @@ -224,6 +236,38 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic } } + function prepareResourcesCpuConfig(config, input) { + // CPU Limit + if (input.CpuLimit > 0) { + config.TaskTemplate.Resources.Limits.NanoCPUs = input.CpuLimit * 1000000000; + } + // CPU Reservation + if (input.CpuReservation > 0) { + config.TaskTemplate.Resources.Reservations.NanoCPUs = input.CpuReservation * 1000000000; + } + } + + function prepareResourcesMemoryConfig(config, input) { + // Memory Limit - Round to 0.125 + var memoryLimit = (Math.round(input.MemoryLimit * 8) / 8).toFixed(3); + memoryLimit *= 1024 * 1024; + if (input.MemoryLimitUnit === 'GB') { + memoryLimit *= 1024; + } + if (memoryLimit > 0) { + config.TaskTemplate.Resources.Limits.MemoryBytes = memoryLimit; + } + // Memory Resevation - Round to 0.125 + var memoryReservation = (Math.round(input.MemoryReservation * 8) / 8).toFixed(3); + memoryReservation *= 1024 * 1024; + if (input.MemoryReservationUnit === 'GB') { + memoryReservation *= 1024; + } + if (memoryReservation > 0) { + config.TaskTemplate.Resources.Reservations.MemoryBytes = memoryReservation; + } + } + function prepareConfiguration() { var input = $scope.formValues; var config = { @@ -232,7 +276,11 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic ContainerSpec: { Mounts: [] }, - Placement: {} + Placement: {}, + Resources: { + Limits: {}, + Reservations: {} + } }, Mode: {}, EndpointSpec: {} @@ -248,6 +296,8 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic prepareUpdateConfig(config, input); prepareSecretConfig(config, input); preparePlacementConfig(config, input); + prepareResourcesCpuConfig(config, input); + prepareResourcesMemoryConfig(config, input); return config; } @@ -305,16 +355,30 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic function initView() { $('#loadingViewSpinner').show(); var apiVersion = $scope.applicationState.endpoint.apiVersion; + var provider = $scope.applicationState.endpoint.mode.provider; $q.all({ volumes: VolumeService.volumes(), secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], - networks: NetworkService.networks(true, true, false, false) + networks: NetworkService.networks(true, true, false, false), + nodes: NodeService.nodes() }) .then(function success(data) { $scope.availableVolumes = data.volumes; $scope.availableNetworks = data.networks; $scope.availableSecrets = data.secrets; + // Set max cpu value + var maxCpus = 0; + for (var n in data.nodes) { + if (data.nodes[n].CPUs && data.nodes[n].CPUs > maxCpus) { + maxCpus = data.nodes[n].CPUs; + } + } + if (maxCpus > 0) { + $scope.state.sliderMaxCpu = maxCpus / 1000000000; + } else { + $scope.state.sliderMaxCpu = 32; + } }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to initialize view'); diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index d34ad9bd1..ad3c254ed 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -133,7 +133,7 @@
  • Labels
  • Update config
  • Secrets
  • -
  • Placement
  • +
  • Resources & Placement
  • @@ -442,9 +442,9 @@
    - -
    - + +
    +
    diff --git a/app/components/createService/includes/placement.html b/app/components/createService/includes/placement.html deleted file mode 100644 index c12547fac..000000000 --- a/app/components/createService/includes/placement.html +++ /dev/null @@ -1,57 +0,0 @@ -
    -
    -
    - - - placement constraint - -
    -
    -
    -
    - name - -
    -
    - -
    -
    - value - -
    - -
    -
    -
    -
    - -
    -
    -
    - - - placement preference - -
    -
    -
    -
    - strategy - -
    -
    - value - -
    - -
    -
    -
    -
    diff --git a/app/components/createService/includes/resources-placement.html b/app/components/createService/includes/resources-placement.html new file mode 100644 index 000000000..6c8b85dd6 --- /dev/null +++ b/app/components/createService/includes/resources-placement.html @@ -0,0 +1,136 @@ +
    +
    + Resources +
    + +
    + +
    + +
    +
    + +
    +
    +

    + Minimum memory available on a node to run a task +

    +
    +
    + + +
    + +
    + +
    +
    + +
    +
    +

    + Maximum memory usage per task (set to 0 for unlimited) +

    +
    +
    + + +
    + +
    + +
    +
    +

    + Minimum CPU available on a node to run a task +

    +
    +
    + + +
    + +
    + +
    +
    +

    + Maximum CPU usage per task +

    +
    +
    + +
    + Placement +
    + +
    +
    + + + placement constraint + +
    +
    +
    +
    + name + +
    +
    + +
    +
    + value + +
    + +
    +
    +
    + + +
    +
    + + + placement preference + +
    +
    +
    +
    + strategy + +
    +
    + value + +
    + +
    +
    +
    + +
    diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index 8ba214666..d3b1426c5 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -125,9 +125,6 @@
    -
    -
    {{ infoData.Driver }} driver
    -
    {{ volumeData.total }}
    Volumes
    diff --git a/app/components/endpoint/endpoint.html b/app/components/endpoint/endpoint.html index 80be7c92c..f7a113ab1 100644 --- a/app/components/endpoint/endpoint.html +++ b/app/components/endpoint/endpoint.html @@ -12,6 +12,9 @@
    +
    + Configuration +
    @@ -42,73 +45,19 @@
    - -
    -
    - - + +
    +
    + Security
    +
    - - -
    - -
    - -
    - - - {{ formValues.TLSCACert.name }} - - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSCert.name }} - - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSKey.name }} - - - - -
    -
    - -
    - +
    - + Cancel - - - {{ state.error }} - +
    diff --git a/app/components/endpoint/endpointController.js b/app/components/endpoint/endpointController.js index 81444370e..bb3803f93 100644 --- a/app/components/endpoint/endpointController.js +++ b/app/components/endpoint/endpointController.js @@ -7,35 +7,41 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) } $scope.state = { - error: '', uploadInProgress: false }; $scope.formValues = { - TLSCACert: null, - TLSCert: null, - TLSKey: null + SecurityFormData: new EndpointSecurityFormData() }; $scope.updateEndpoint = function() { - var ID = $scope.endpoint.Id; + var endpoint = $scope.endpoint; + var securityData = $scope.formValues.SecurityFormData; + var TLS = securityData.TLS; + var TLSMode = securityData.TLSMode; + var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only'); + var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only'); + var endpointParams = { - name: $scope.endpoint.Name, - URL: $scope.endpoint.URL, - PublicURL: $scope.endpoint.PublicURL, - TLS: $scope.endpoint.TLS, - TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null, - TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null, - TLSKey: $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null, + name: endpoint.Name, + URL: endpoint.URL, + PublicURL: endpoint.PublicURL, + TLS: TLS, + TLSSkipVerify: TLSSkipVerify, + TLSSkipClientVerify: TLSSkipClientVerify, + TLSCACert: TLSSkipVerify || securityData.TLSCACert === endpoint.TLSConfig.TLSCACert ? null : securityData.TLSCACert, + TLSCert: TLSSkipClientVerify || securityData.TLSCert === endpoint.TLSConfig.TLSCert ? null : securityData.TLSCert, + TLSKey: TLSSkipClientVerify || securityData.TLSKey === endpoint.TLSConfig.TLSKey ? null : securityData.TLSKey, type: $scope.endpointType }; - EndpointService.updateEndpoint(ID, endpointParams) + $('updateResourceSpinner').show(); + EndpointService.updateEndpoint(endpoint.Id, endpointParams) .then(function success(data) { Notifications.success('Endpoint updated', $scope.endpoint.Name); $state.go('endpoints'); }, function error(err) { - $scope.state.error = err.msg; + Notifications.error('Failure', err, 'Unable to update endpoint'); }, function update(evt) { if (evt.upload) { $scope.state.uploadInProgress = evt.upload; @@ -43,25 +49,27 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) }); }; - function getEndpoint(endpointID) { + function initView() { $('#loadingViewSpinner').show(); - EndpointService.endpoint($stateParams.id).then(function success(data) { - $('#loadingViewSpinner').hide(); - $scope.endpoint = data; - if (data.URL.indexOf('unix://') === 0) { + EndpointService.endpoint($stateParams.id) + .then(function success(data) { + var endpoint = data; + endpoint.URL = $filter('stripprotocol')(endpoint.URL); + $scope.endpoint = endpoint; + + if (endpoint.URL.indexOf('unix://') === 0) { $scope.endpointType = 'local'; } else { $scope.endpointType = 'remote'; } - $scope.endpoint.URL = $filter('stripprotocol')(data.URL); - $scope.formValues.TLSCACert = data.TLSCACert; - $scope.formValues.TLSCert = data.TLSCert; - $scope.formValues.TLSKey = data.TLSKey; - }, function error(err) { - $('#loadingViewSpinner').hide(); + }) + .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); }); } - getEndpoint($stateParams.id); + initView(); }]); diff --git a/app/components/endpointInit/endpointInit.html b/app/components/endpointInit/endpointInit.html deleted file mode 100644 index 8c7abf963..000000000 --- a/app/components/endpointInit/endpointInit.html +++ /dev/null @@ -1,153 +0,0 @@ -
    - -
    -
    - -
    - - -
    - - -
    -
    - -
    - -
    -

    Connect Portainer to a Docker engine or Swarm cluster endpoint

    -
    - - -
    -
    - -
    -
    - -
    -
    - - -
    -
    - - This feature is not yet available for native Docker Windows containers. -
    On Linux and when using Docker for Mac or Docker for Windows or Docker Toolbox, ensure that you have started Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock"
    -
    - -
    -
    -

    - {{ state.error }} -

    - - - - -
    -
    - -
    - - -
    - -
    - -
    - -
    -
    - - -
    - -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    - -
    - -
    - - - {{ formValues.TLSCACert.name }} - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSCert.name }} - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSKey.name }} - - - -
    -
    - -
    - - -
    -
    -

    - {{ state.error }} -

    - - - - -
    -
    - -
    - -
    - -
    -
    - -
    -
    - -
    diff --git a/app/components/endpointInit/endpointInitController.js b/app/components/endpointInit/endpointInitController.js deleted file mode 100644 index c99a9a86c..000000000 --- a/app/components/endpointInit/endpointInitController.js +++ /dev/null @@ -1,91 +0,0 @@ -angular.module('endpointInit', []) -.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', -function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications) { - $scope.state = { - error: '', - uploadInProgress: false - }; - - $scope.formValues = { - endpointType: 'remote', - Name: '', - URL: '', - TLS: false, - TLSCACert: null, - TLSCert: null, - TLSKey: null - }; - - if (!_.isEmpty($scope.applicationState.endpoint)) { - $state.go('dashboard'); - } - - $scope.resetErrorMessage = function() { - $scope.state.error = ''; - }; - - function showErrorMessage(message) { - $scope.state.uploadInProgress = false; - $scope.state.error = message; - } - - function updateEndpointState(endpointID) { - EndpointProvider.setEndpointID(endpointID); - StateManager.updateEndpointState(false) - .then(function success(data) { - $state.go('dashboard'); - }) - .catch(function error(err) { - EndpointService.deleteEndpoint(endpointID) - .then(function success() { - showErrorMessage('Unable to connect to the Docker endpoint'); - }); - }); - } - - $scope.createLocalEndpoint = function() { - $('#initEndpointSpinner').show(); - $scope.state.error = ''; - var name = 'local'; - var URL = 'unix:///var/run/docker.sock'; - var TLS = false; - - EndpointService.createLocalEndpoint(name, URL, TLS, true) - .then(function success(data) { - var endpointID = data.Id; - updateEndpointState(data.Id); - }, function error() { - $scope.state.error = 'Unable to create endpoint'; - }) - .finally(function final() { - $('#initEndpointSpinner').hide(); - }); - }; - - $scope.createRemoteEndpoint = function() { - $('#initEndpointSpinner').show(); - $scope.state.error = ''; - var name = $scope.formValues.Name; - var URL = $scope.formValues.URL; - var PublicURL = URL.split(':')[0]; - var TLS = $scope.formValues.TLS; - var TLSCAFile = $scope.formValues.TLSCACert; - var TLSCertFile = $scope.formValues.TLSCert; - var TLSKeyFile = $scope.formValues.TLSKey; - - EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) - .then(function success(data) { - var endpointID = data.Id; - updateEndpointState(endpointID); - }, function error(err) { - showErrorMessage(err.msg); - }, function update(evt) { - if (evt.upload) { - $scope.state.uploadInProgress = evt.upload; - } - }) - .finally(function final() { - $('#initEndpointSpinner').hide(); - }); - }; -}]); diff --git a/app/components/endpoints/endpoints.html b/app/components/endpoints/endpoints.html index 6528ae711..4812eabe8 100644 --- a/app/components/endpoints/endpoints.html +++ b/app/components/endpoints/endpoints.html @@ -60,75 +60,21 @@
    - + + + +
    - - -
    + +
    - - -
    - -
    - -
    - - - {{ formValues.TLSCACert.name }} - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSCert.name }} - - - -
    -
    - - -
    - -
    - - - {{ formValues.TLSKey.name }} - - - -
    -
    - -
    - -
    -
    - - - - {{ state.error }} - -
    -
    - - - -
    +
    + + + + +
    @@ -191,7 +137,7 @@ Manage access - + Loading... diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index c7b488b3c..9a761a164 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -2,7 +2,6 @@ angular.module('endpoints', []) .controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination', function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) { $scope.state = { - error: '', uploadInProgress: false, selectedItemCount: 0, pagination_count: Pagination.getPaginationCount('endpoints') @@ -14,10 +13,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi Name: '', URL: '', PublicURL: '', - TLS: false, - TLSCACert: null, - TLSCert: null, - TLSKey: null + SecurityFormData: new EndpointSecurityFormData() }; $scope.order = function(sortType) { @@ -47,23 +43,28 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi }; $scope.addEndpoint = function() { - $scope.state.error = ''; var name = $scope.formValues.Name; var URL = $scope.formValues.URL; var PublicURL = $scope.formValues.PublicURL; if (PublicURL === '') { PublicURL = URL.split(':')[0]; } - var TLS = $scope.formValues.TLS; - var TLSCAFile = $scope.formValues.TLSCACert; - var TLSCertFile = $scope.formValues.TLSCert; - var TLSKeyFile = $scope.formValues.TLSKey; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) { + + var securityData = $scope.formValues.SecurityFormData; + var TLS = securityData.TLS; + var TLSMode = securityData.TLSMode; + var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only'); + var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only'); + var TLSCAFile = TLSSkipVerify ? null : securityData.TLSCACert; + var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; + var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; + + EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) { Notifications.success('Endpoint created', name); $state.reload(); }, function error(err) { $scope.state.uploadInProgress = false; - $scope.state.error = err.msg; + Notifications.error('Failure', err, 'Unable to create endpoint'); }, function update(evt) { if (evt.upload) { $scope.state.uploadInProgress = evt.upload; diff --git a/app/components/docker/docker.html b/app/components/engine/engine.html similarity index 98% rename from app/components/docker/docker.html rename to app/components/engine/engine.html index 67aa6db5b..e81f1cdbf 100644 --- a/app/components/docker/docker.html +++ b/app/components/engine/engine.html @@ -1,6 +1,6 @@ - + diff --git a/app/components/docker/dockerController.js b/app/components/engine/engineController.js similarity index 79% rename from app/components/docker/dockerController.js rename to app/components/engine/engineController.js index 4e3a160dc..9d7c04163 100644 --- a/app/components/docker/dockerController.js +++ b/app/components/engine/engineController.js @@ -1,9 +1,7 @@ -angular.module('docker', []) -.controller('DockerController', ['$q', '$scope', 'SystemService', 'Notifications', +angular.module('engine', []) +.controller('EngineController', ['$q', '$scope', 'SystemService', 'Notifications', function ($q, $scope, SystemService, Notifications) { - $scope.info = {}; - $scope.version = {}; - + function initView() { $('#loadingViewSpinner').show(); $q.all({ @@ -15,6 +13,8 @@ function ($q, $scope, SystemService, Notifications) { $scope.info = data.info; }) .catch(function error(err) { + $scope.info = {}; + $scope.version = {}; Notifications.error('Failure', err, 'Unable to retrieve engine details'); }) .finally(function final() { diff --git a/app/components/images/images.html b/app/components/images/images.html index 5959c0bf2..8e82ff1a6 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -70,7 +70,7 @@
    - + @@ -121,12 +121,12 @@ - + {{ image.Id|truncate:20}} + ng-if="::image.ContainerCount === 0"> Unused diff --git a/app/components/images/imagesController.js b/app/components/images/imagesController.js index e8c9ca30f..6770f44a9 100644 --- a/app/components/images/imagesController.js +++ b/app/components/images/imagesController.js @@ -95,7 +95,7 @@ function ($scope, $state, ImageService, Notifications, Pagination, ModalService) $('#loadImagesSpinner').show(); var endpointProvider = $scope.applicationState.endpoint.mode.provider; var apiVersion = $scope.applicationState.endpoint.apiVersion; - ImageService.images(apiVersion >= 1.25 && endpointProvider !== 'DOCKER_SWARM' && endpointProvider !== 'VMWARE_VIC') + ImageService.images(true) .then(function success(data) { $scope.images = data; }) diff --git a/app/components/initAdmin/initAdmin.html b/app/components/initAdmin/initAdmin.html new file mode 100644 index 000000000..6fbcccaf9 --- /dev/null +++ b/app/components/initAdmin/initAdmin.html @@ -0,0 +1,80 @@ +
    + +
    +
    + +
    + + +
    + + +
    +
    + +
    + +
    +
    + + Please create the initial administrator user. + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    +
    + + +
    +
    +
    + + +
    +
    + + + The password must be at least 8 characters long + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +
    + +
    +
    + +
    diff --git a/app/components/initAdmin/initAdminController.js b/app/components/initAdmin/initAdminController.js new file mode 100644 index 000000000..0959fcd2e --- /dev/null +++ b/app/components/initAdmin/initAdminController.js @@ -0,0 +1,48 @@ +angular.module('initAdmin', []) +.controller('InitAdminController', ['$scope', '$state', '$sanitize', 'Notifications', 'Authentication', 'StateManager', 'UserService', 'EndpointService', 'EndpointProvider', +function ($scope, $state, $sanitize, Notifications, Authentication, StateManager, UserService, EndpointService, EndpointProvider) { + + $scope.logo = StateManager.getState().application.logo; + + $scope.formValues = { + Username: 'admin', + Password: '', + ConfirmPassword: '' + }; + + $scope.createAdminUser = function() { + $('#createResourceSpinner').show(); + var username = $sanitize($scope.formValues.Username); + var password = $sanitize($scope.formValues.Password); + + UserService.initAdministrator(username, password) + .then(function success() { + return Authentication.login(username, password); + }) + .then(function success() { + return EndpointService.endpoints(); + }) + .then(function success(data) { + if (data.length === 0) { + $state.go('init.endpoint'); + } else { + var endpointID = data[0].Id; + EndpointProvider.setEndpointID(endpointID); + StateManager.updateEndpointState(false) + .then(function success() { + $state.go('dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to Docker environment'); + }); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create administrator user'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + +}]); diff --git a/app/components/initEndpoint/initEndpoint.html b/app/components/initEndpoint/initEndpoint.html new file mode 100644 index 000000000..b7722989b --- /dev/null +++ b/app/components/initEndpoint/initEndpoint.html @@ -0,0 +1,202 @@ +
    + +
    +
    + +
    + + +
    + + +
    +
    + +
    + +
    +
    + + Connect Portainer to the Docker environment you want to manage. + +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +

    + + This feature is not yet available for native Docker Windows containers. +

    +

    + Please ensure that you have started the Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock" in order to connect to the local Docker environment. +

    +
    +
    +
    + +
    +
    + + +
    +
    + +
    + + +
    + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + Required TLS files +
    + +
    + +
    + + + {{ formValues.TLSCACert.name }} + + + +
    +
    + +
    + +
    + +
    + + + {{ formValues.TLSCert.name }} + + + +
    +
    + + +
    + +
    + + + {{ formValues.TLSKey.name }} + + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    + +
    diff --git a/app/components/initEndpoint/initEndpointController.js b/app/components/initEndpoint/initEndpointController.js new file mode 100644 index 000000000..7b72f7f73 --- /dev/null +++ b/app/components/initEndpoint/initEndpointController.js @@ -0,0 +1,81 @@ +angular.module('initEndpoint', []) +.controller('InitEndpointController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', +function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications) { + + if (!_.isEmpty($scope.applicationState.endpoint)) { + $state.go('dashboard'); + } + + $scope.logo = StateManager.getState().application.logo; + + $scope.state = { + uploadInProgress: false + }; + + $scope.formValues = { + EndpointType: 'remote', + Name: '', + URL: '', + TLS: false, + TLSSkipVerify: false, + TLSSKipClientVerify: false, + TLSCACert: null, + TLSCert: null, + TLSKey: null + }; + + $scope.createLocalEndpoint = function() { + $('#createResourceSpinner').show(); + var name = 'local'; + var URL = 'unix:///var/run/docker.sock'; + + var endpointID = 1; + EndpointService.createLocalEndpoint(name, URL, false, true) + .then(function success(data) { + endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); + return StateManager.updateEndpointState(false); + }) + .then(function success(data) { + $state.go('dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); + EndpointService.deleteEndpoint(endpointID); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + + $scope.createRemoteEndpoint = function() { + $('#createResourceSpinner').show(); + var name = $scope.formValues.Name; + var URL = $scope.formValues.URL; + var PublicURL = URL.split(':')[0]; + var TLS = $scope.formValues.TLS; + var TLSSkipVerify = TLS && $scope.formValues.TLSSkipVerify; + var TLSSKipClientVerify = TLS && $scope.formValues.TLSSKipClientVerify; + var TLSCAFile = TLSSkipVerify ? null : $scope.formValues.TLSCACert; + var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; + var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; + + var endpointID = 1; + EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + .then(function success(data) { + endpointID = data.Id; + EndpointProvider.setEndpointID(endpointID); + return StateManager.updateEndpointState(false); + }) + .then(function success(data) { + $state.go('dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); + EndpointService.deleteEndpoint(endpointID); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; +}]); diff --git a/app/components/network/network.html b/app/components/network/network.html index d5eacd7f1..8439a7e9f 100644 --- a/app/components/network/network.html +++ b/app/components/network/network.html @@ -48,6 +48,15 @@
    + + + + +
    diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index 63f9cb4e8..0af567512 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -1,6 +1,6 @@ angular.module('network', []) -.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'Container', 'ContainerHelper', 'Notifications', -function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHelper, Notifications) { +.controller('NetworkController', ['$scope', '$state', '$stateParams', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', +function ($scope, $state, $stateParams, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { $scope.removeNetwork = function removeNetwork(networkId) { $('#loadingViewSpinner').show(); @@ -82,7 +82,7 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe function initView() { $('#loadingViewSpinner').show(); - Network.get({id: $stateParams.id}).$promise + NetworkService.network($stateParams.id) .then(function success(data) { $scope.network = data; var endpointProvider = $scope.applicationState.endpoint.mode.provider; diff --git a/app/components/networks/networks.html b/app/components/networks/networks.html index 7e8442cff..4773aa779 100644 --- a/app/components/networks/networks.html +++ b/app/components/networks/networks.html @@ -8,46 +8,6 @@ Networks -
    -
    - - - - -
    - -
    - -
    - -
    -
    - - -
    -
    - Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster. -
    -
    -
    -
    - Note: The network will be created using the bridge driver. -
    -
    - -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    @@ -66,6 +26,7 @@
    + Add network
    @@ -80,54 +41,61 @@ - + Name - + Id - + Scope - + Driver - + IPAM Driver - + IPAM Subnet - + IPAM Gateway + + + Ownership + + + + @@ -140,12 +108,18 @@ {{ network.IPAM.Driver }} {{ network.IPAM.Config[0].Subnet ? network.IPAM.Config[0].Subnet : '-' }} {{ network.IPAM.Config[0].Gateway ? network.IPAM.Config[0].Gateway : '-' }} + + + + {{ network.ResourceControl.Ownership ? network.ResourceControl.Ownership : network.ResourceControl.Ownership = 'public' }} + + - Loading... + Loading... - No networks available. + No networks available. diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 8468a1283..1909358ed 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -1,51 +1,17 @@ angular.module('networks', []) -.controller('NetworksController', ['$scope', '$state', 'Network', 'Notifications', 'Pagination', -function ($scope, $state, Network, Notifications, Pagination) { +.controller('NetworksController', ['$scope', '$state', 'Network', 'NetworkService', 'Notifications', 'Pagination', +function ($scope, $state, Network, NetworkService, Notifications, Pagination) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('networks'); $scope.state.selectedItemCount = 0; $scope.state.advancedSettings = false; $scope.sortType = 'Name'; $scope.sortReverse = false; - $scope.config = { - Name: '' - }; $scope.changePaginationCount = function() { Pagination.setPaginationCount('networks', $scope.state.pagination_count); }; - function prepareNetworkConfiguration() { - var config = angular.copy($scope.config); - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - config.Driver = 'overlay'; - // Force IPAM Driver to 'default', should not be required. - // See: https://github.com/docker/docker/issues/25735 - config.IPAM = { - Driver: 'default' - }; - } - return config; - } - - $scope.createNetwork = function() { - $('#createNetworkSpinner').show(); - var config = prepareNetworkConfiguration(); - Network.create(config, function (d) { - if (d.message) { - $('#createNetworkSpinner').hide(); - Notifications.error('Unable to create network', {}, d.message); - } else { - Notifications.success('Network created', d.Id); - $('#createNetworkSpinner').hide(); - $state.reload(); - } - }, function (e) { - $('#createNetworkSpinner').hide(); - Notifications.error('Failure', e, 'Unable to create network'); - }); - }; - $scope.order = function(sortType) { $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; $scope.sortType = sortType; @@ -99,13 +65,17 @@ function ($scope, $state, Network, Notifications, Pagination) { function initView() { $('#loadNetworksSpinner').show(); - Network.query({}, function (d) { - $scope.networks = d; - $('#loadNetworksSpinner').hide(); - }, function (e) { - $('#loadNetworksSpinner').hide(); - Notifications.error('Failure', e, 'Unable to retrieve networks'); + + NetworkService.networks(true, true, true, true) + .then(function success(data) { + $scope.networks = data; + }) + .catch(function error(err) { $scope.networks = []; + Notifications.error('Failure', err, 'Unable to retrieve networks'); + }) + .finally(function final() { + $('#loadNetworksSpinner').hide(); }); } diff --git a/app/components/secret/secret.html b/app/components/secret/secret.html index fb349ebbb..e90fd7420 100644 --- a/app/components/secret/secret.html +++ b/app/components/secret/secret.html @@ -53,3 +53,12 @@
    + + + + + diff --git a/app/components/secrets/secrets.html b/app/components/secrets/secrets.html index abd9ba6eb..b274ae777 100644 --- a/app/components/secrets/secrets.html +++ b/app/components/secrets/secrets.html @@ -30,31 +30,44 @@ - + Name - + Created at + + + Ownership + + + + {{ secret.Name }} {{ secret.CreatedAt | getisodate }} + + + + {{ secret.ResourceControl.Ownership ? secret.ResourceControl.Ownership : secret.ResourceControl.Ownership = 'public' }} + + - Loading... + Loading... - No secrets available. + No secrets available. diff --git a/app/components/service/includes/resources.html b/app/components/service/includes/resources.html index 12228cb2f..e24de75b5 100644 --- a/app/components/service/includes/resources.html +++ b/app/components/service/includes/resources.html @@ -6,31 +6,77 @@ - - - - - - - - - - - - + - - - - + + + + + + + + + + + + +
    CPU limits - {{ service.LimitNanoCPUs / 1000000000 }} + + Memory reservation (MB) None
    Memory limits{{service.LimitMemoryBytes|humansize}}None
    CPU reservation - {{service.ReservationNanoCPUs / 1000000000}} + + + +

    + Minimum memory available on a node to run a task (set to 0 for unlimited) +

    None
    Memory reservation{{service.ReservationMemoryBytes|humansize}}None + Memory limit (MB) + + + +

    + Maximum memory usage per task (set to 0 for unlimited) +

    +
    +
    + CPU reservation +
    +
    + + +

    + Minimum CPU available on a node to run a task +

    +
    +
    + CPU limit +
    +
    + + +

    + Maximum CPU usage per task +

    +
    + + +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 3b5f8e2a1..775525aa1 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -204,14 +204,19 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(service.ServiceConstraints); config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(service.ServicePreferences); + // Round memory values to 0.125 and convert MB to B + var memoryLimit = (Math.round(service.LimitMemoryBytes * 8) / 8).toFixed(3); + memoryLimit *= 1024 * 1024; + var memoryReservation = (Math.round(service.ReservationMemoryBytes * 8) / 8).toFixed(3); + memoryReservation *= 1024 * 1024; config.TaskTemplate.Resources = { Limits: { - NanoCPUs: service.LimitNanoCPUs, - MemoryBytes: service.LimitMemoryBytes + NanoCPUs: service.LimitNanoCPUs * 1000000000, + MemoryBytes: memoryLimit }, Reservations: { - NanoCPUs: service.ReservationNanoCPUs, - MemoryBytes: service.ReservationMemoryBytes + NanoCPUs: service.ReservationNanoCPUs * 1000000000, + MemoryBytes: memoryReservation } }; @@ -244,7 +249,11 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, Service.update({ id: service.Id, version: service.Version }, config, function (data) { $('#loadingViewSpinner').hide(); - Notifications.success('Service successfully updated', 'Service updated'); + if (data.message && data.message.match(/^rpc error:/)) { + Notifications.error(data.message, 'Error'); + } else { + Notifications.success('Service successfully updated', 'Service updated'); + } $scope.cancelChanges({}); initView(); }, function (e) { @@ -288,6 +297,13 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, service.ServicePreferences = ServiceHelper.translatePreferencesToKeyValue(service.Preferences); } + function transformResources(service) { + service.LimitNanoCPUs = service.LimitNanoCPUs / 1000000000 || 0; + service.ReservationNanoCPUs = service.ReservationNanoCPUs / 1000000000 || 0; + service.LimitMemoryBytes = service.LimitMemoryBytes / 1024 / 1024 || 0; + service.ReservationMemoryBytes = service.ReservationMemoryBytes / 1024 / 1024 || 0; + } + function initView() { $('#loadingViewSpinner').show(); var apiVersion = $scope.applicationState.endpoint.apiVersion; @@ -299,6 +315,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, $scope.lastVersion = service.Version; } + transformResources(service); translateServiceArrays(service); $scope.service = service; originalService = angular.copy(service); @@ -314,6 +331,19 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, $scope.nodes = data.nodes; $scope.secrets = data.secrets; + // Set max cpu value + var maxCpus = 0; + for (var n in data.nodes) { + if (data.nodes[n].CPUs && data.nodes[n].CPUs > maxCpus) { + maxCpus = data.nodes[n].CPUs; + } + } + if (maxCpus > 0) { + $scope.state.sliderMaxCpu = maxCpus / 1000000000; + } else { + $scope.state.sliderMaxCpu = 32; + } + $timeout(function() { $anchorScroll(); }); diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 068dab150..aa61d2ce5 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -43,14 +43,14 @@ -
    - +
    diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index 9a8d90455..347b11d9e 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -151,7 +151,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer $q.all({ templates: TemplateService.getTemplates(templatesKey), - containers: ContainerService.getContainers(0), + containers: ContainerService.containers(0), volumes: VolumeService.getVolumes(), networks: NetworkService.networks( provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', diff --git a/app/directives/autofocus.js b/app/directives/autofocus.js new file mode 100644 index 000000000..0b9029c33 --- /dev/null +++ b/app/directives/autofocus.js @@ -0,0 +1,14 @@ +angular +.module('portainer') +.directive('autoFocus', ['$timeout', function porAutoFocus($timeout) { + var directive = { + restrict: 'A', + link: function($scope, $element) { + $timeout(function() { + $element[0].focus(); + }); + } + }; + + return directive; +}]); diff --git a/app/directives/endpointSecurity/por-endpoint-security.js b/app/directives/endpointSecurity/por-endpoint-security.js new file mode 100644 index 000000000..51567dc18 --- /dev/null +++ b/app/directives/endpointSecurity/por-endpoint-security.js @@ -0,0 +1,12 @@ +angular.module('portainer').component('porEndpointSecurity', { + templateUrl: 'app/directives/endpointSecurity/porEndpointSecurity.html', + controller: 'porEndpointSecurityController', + bindings: { + // This object will be populated with the form data. + // Model reference in endpointSecurityModel.js + formData: '=', + // The component will use this object to initialize the default values + // if present. + endpoint: '<' + } +}); diff --git a/app/directives/endpointSecurity/porEndpointSecurity.html b/app/directives/endpointSecurity/porEndpointSecurity.html new file mode 100644 index 000000000..ca86ce940 --- /dev/null +++ b/app/directives/endpointSecurity/porEndpointSecurity.html @@ -0,0 +1,126 @@ +
    + +
    +
    + + +
    +
    + +
    + TLS mode +
    + +
    +
    + + You can find out more information about how to protect a Docker environment with TLS in the Docker documentation. + +
    +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + Required TLS files +
    + +
    + +
    + +
    + + + {{ $ctrl.formData.TLSCACert.name }} + + + + +
    +
    + + +
    + +
    + +
    + + + {{ $ctrl.formData.TLSCert.name }} + + + + +
    +
    + + +
    + +
    + + + {{ $ctrl.formData.TLSKey.name }} + + + + +
    +
    + +
    + +
    + +
    diff --git a/app/directives/endpointSecurity/porEndpointSecurityController.js b/app/directives/endpointSecurity/porEndpointSecurityController.js new file mode 100644 index 000000000..059903047 --- /dev/null +++ b/app/directives/endpointSecurity/porEndpointSecurityController.js @@ -0,0 +1,32 @@ +angular.module('portainer') +.controller('porEndpointSecurityController', [function () { + var ctrl = this; + + function initComponent() { + if (ctrl.endpoint) { + var endpoint = ctrl.endpoint; + var TLS = endpoint.TLSConfig.TLS; + ctrl.formData.TLS = TLS; + var CACert = endpoint.TLSConfig.TLSCACert; + ctrl.formData.TLSCACert = CACert; + var cert = endpoint.TLSConfig.TLSCert; + ctrl.formData.TLSCert = cert; + var key = endpoint.TLSConfig.TLSKey; + ctrl.formData.TLSKey = key; + + if (TLS) { + if (CACert && cert && key) { + ctrl.formData.TLSMode = 'tls_client_ca'; + } else if (cert && key) { + ctrl.formData.TLSMode = 'tls_client_noca'; + } else if (CACert) { + ctrl.formData.TLSMode = 'tls_ca'; + } else { + ctrl.formData.TLSMode = 'tls_only'; + } + } + } + } + + initComponent(); +}]); diff --git a/app/directives/endpointSecurity/porEndpointSecurityModel.js b/app/directives/endpointSecurity/porEndpointSecurityModel.js new file mode 100644 index 000000000..94f6b0be2 --- /dev/null +++ b/app/directives/endpointSecurity/porEndpointSecurityModel.js @@ -0,0 +1,7 @@ +function EndpointSecurityFormData() { + this.TLS = false; + this.TLSMode = 'tls_client_ca'; + this.TLSCACert = null; + this.TLSCert = null; + this.TLSKey = null; +} diff --git a/app/directives/slider/por-slider.js b/app/directives/slider/por-slider.js new file mode 100644 index 000000000..a44104789 --- /dev/null +++ b/app/directives/slider/por-slider.js @@ -0,0 +1,12 @@ +angular.module('portainer').component('porSlider', { + templateUrl: 'app/directives/slider/porSlider.html', + controller: 'porSliderController', + bindings: { + model: '=', + onChange: '&', + floor: '<', + ceil: '<', + step: '<', + precision: '<' + } +}); diff --git a/app/directives/slider/porSlider.html b/app/directives/slider/porSlider.html new file mode 100644 index 000000000..94cb04a75 --- /dev/null +++ b/app/directives/slider/porSlider.html @@ -0,0 +1,3 @@ +
    + +
    diff --git a/app/directives/slider/porSliderController.js b/app/directives/slider/porSliderController.js new file mode 100644 index 000000000..4958800b2 --- /dev/null +++ b/app/directives/slider/porSliderController.js @@ -0,0 +1,22 @@ +angular.module('portainer') +.controller('porSliderController', function () { + var ctrl = this; + + ctrl.options = { + floor: ctrl.floor, + ceil: ctrl.ceil, + step: ctrl.step, + precision: ctrl.precision, + showSelectionBar: true, + translate: function(value, sliderId, label) { + if (label === 'floor' || value === 0) { + return 'unlimited'; + } + return value; + }, + onChange: function() { + ctrl.onChange(); + } + }; + +}); diff --git a/app/filters/filters.js b/app/filters/filters.js index b4cac3fe0..e1d61bd95 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -37,6 +37,20 @@ angular.module('portainer.filters', []) } }; }) +.filter('visualizerTask', function () { + 'use strict'; + return function (text) { + var status = _.toLower(text); + if (includeString(status, ['new', 'allocated', 'assigned', 'accepted', 'complete', 'preparing'])) { + return 'info'; + } else if (includeString(status, ['pending'])) { + return 'warning'; + } else if (includeString(status, ['shutdown', 'failed', 'rejected'])) { + return 'stopped'; + } + return 'running'; + }; +}) .filter('taskstatusbadge', function () { 'use strict'; return function (text) { diff --git a/app/helpers/serviceHelper.js b/app/helpers/serviceHelper.js index bddab33c6..ec3411421 100644 --- a/app/helpers/serviceHelper.js +++ b/app/helpers/serviceHelper.js @@ -119,6 +119,5 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe } return []; } - }; }]); diff --git a/app/models/api/template.js b/app/models/api/template.js index 01532f6f6..0123f92e4 100644 --- a/app/models/api/template.js +++ b/app/models/api/template.js @@ -16,11 +16,20 @@ function TemplateViewModel(data) { this.Volumes = []; if (data.volumes) { this.Volumes = data.volumes.map(function (v) { - return { - readOnly: false, - containerPath: v, + // @DEPRECATED: New volume definition introduced + // via https://github.com/portainer/portainer/pull/1154 + var volume = { + readOnly: v.readonly || false, + containerPath: v.container || v, type: 'auto' }; + + if (v.bind) { + volume.name = v.bind; + volume.type = 'bind'; + } + + return volume; }); } this.Ports = []; diff --git a/app/models/docker/containerStats.js b/app/models/docker/containerStats.js new file mode 100644 index 000000000..aad3b48b3 --- /dev/null +++ b/app/models/docker/containerStats.js @@ -0,0 +1,12 @@ +function ContainerStatsViewModel(data) { + this.Date = data.read; + this.MemoryUsage = data.memory_stats.usage; + this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage; + this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage; + this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage; + this.CurrentCPUSystemUsage = data.cpu_stats.system_cpu_usage; + if (data.cpu_stats.cpu_usage.percpu_usage) { + this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length; + } + this.Networks = _.values(data.networks); +} diff --git a/app/models/docker/image.js b/app/models/docker/image.js index 43301787f..5dd073800 100644 --- a/app/models/docker/image.js +++ b/app/models/docker/image.js @@ -3,8 +3,8 @@ function ImageViewModel(data) { this.Tag = data.Tag; this.Repository = data.Repository; this.Created = data.Created; - this.Containers = data.dataUsage ? data.dataUsage.Containers : 0; this.Checked = false; this.RepoTags = data.RepoTags; this.VirtualSize = data.VirtualSize; + this.ContainerCount = data.ContainerCount; } diff --git a/app/models/docker/network.js b/app/models/docker/network.js new file mode 100644 index 000000000..820b35ab6 --- /dev/null +++ b/app/models/docker/network.js @@ -0,0 +1,16 @@ +function NetworkViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Scope = data.Scope; + this.Driver = data.Driver; + this.Attachable = data.Attachable; + this.IPAM = data.IPAM; + this.Containers = data.Containers; + this.Options = data.Options; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/models/docker/secret.js b/app/models/docker/secret.js index 112419791..d6c54c22e 100644 --- a/app/models/docker/secret.js +++ b/app/models/docker/secret.js @@ -5,4 +5,10 @@ function SecretViewModel(data) { this.Version = data.Version.Index; this.Name = data.Spec.Name; this.Labels = data.Spec.Labels; + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } } diff --git a/app/rest/docker/container.js b/app/rest/docker/container.js index 1bad5758f..d8786cb03 100644 --- a/app/rest/docker/container.js +++ b/app/rest/docker/container.js @@ -13,7 +13,14 @@ angular.module('portainer.rest') kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, - stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000}, + stats: { + method: 'GET', params: { id: '@id', stream: false, action: 'stats' }, + timeout: 4500 + }, + top: { + method: 'GET', params: { id: '@id', action: 'top' }, + timeout: 4500 + }, start: { method: 'POST', params: {id: '@id', action: 'start'}, transformResponse: genericHandler diff --git a/app/rest/docker/containerTop.js b/app/rest/docker/containerTop.js deleted file mode 100644 index 57e51d51c..000000000 --- a/app/rest/docker/containerTop.js +++ /dev/null @@ -1,17 +0,0 @@ -angular.module('portainer.rest') -.factory('ContainerTop', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { - 'use strict'; - return { - get: function (id, params, callback, errorCallback) { - $http({ - method: 'GET', - url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/top', - params: { - ps_args: params.ps_args - } - }).success(callback).error(function (data, status, headers, config) { - console.log(data); - }); - } - }; -}]); diff --git a/app/services/api/endpointService.js b/app/services/api/endpointService.js index 693f9aea9..742b2fc3c 100644 --- a/app/services/api/endpointService.js +++ b/app/services/api/endpointService.js @@ -20,6 +20,8 @@ angular.module('portainer.services') name: endpointParams.name, PublicURL: endpointParams.PublicURL, TLS: endpointParams.TLS, + TLSSkipVerify: endpointParams.TLSSkipVerify, + TLSSkipClientVerify: endpointParams.TLSSkipClientVerify, authorizedUsers: endpointParams.authorizedUsers }; if (endpointParams.type && endpointParams.URL) { @@ -55,18 +57,20 @@ angular.module('portainer.services') return Endpoints.create({}, endpoint).$promise; }; - service.createRemoteEndpoint = function(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint = { Name: name, URL: 'tcp://' + URL, PublicURL: PublicURL, - TLS: TLS + TLS: TLS, + TLSSkipVerify: TLSSkipVerify, + TLSSkipClientVerify: TLSSkipClientVerify }; var deferred = $q.defer(); Endpoints.create({}, endpoint).$promise .then(function success(data) { var endpointID = data.Id; - if (TLS) { + if (!TLSSkipVerify || !TLSSkipClientVerify) { deferred.notify({upload: true}); FileUploadService.uploadTLSFilesForEndpoint(endpointID, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { diff --git a/app/services/api/userService.js b/app/services/api/userService.js index 7e3bf2b66..50680d7df 100644 --- a/app/services/api/userService.js +++ b/app/services/api/userService.js @@ -134,5 +134,26 @@ angular.module('portainer.services') return deferred.promise; }; + service.initAdministrator = function(username, password) { + return Users.initAdminUser({ Username: username, Password: password }).$promise; + }; + + service.administratorExists = function() { + var deferred = $q.defer(); + + Users.checkAdminUser({}).$promise + .then(function success(data) { + deferred.resolve(true); + }) + .catch(function error(err) { + if (err.status === 404) { + deferred.resolve(false); + } + deferred.reject({ msg: 'Unable to verify administrator account existence', err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/services/chartService.js b/app/services/chartService.js new file mode 100644 index 000000000..5dcacce80 --- /dev/null +++ b/app/services/chartService.js @@ -0,0 +1,251 @@ +angular.module('portainer.services') +.factory('ChartService', [function ChartService() { + 'use strict'; + + // Max. number of items to display on a chart + var CHART_LIMIT = 600; + + var service = {}; + + service.CreateCPUChart = function(context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'CPU', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: { + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: 'nearest', + callbacks: { + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; + return percentageBasedTooltipLabel(datasetLabel, tooltipItem.yLabel); + } + } + }, + hover: { + animationDuration: 0 + }, + scales: { + yAxes: [ + { + ticks: { + beginAtZero: true, + callback: percentageBasedAxisLabel + } + } + ] + } + } + }); + }; + + service.CreateMemoryChart = function(context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Memory', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: { + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: 'nearest', + callbacks: { + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; + return byteBasedTooltipLabel(datasetLabel, tooltipItem.yLabel); + } + } + }, + hover: { + animationDuration: 0 + }, + scales: { + yAxes: [ + { + ticks: { + beginAtZero: true, + callback: byteBasedAxisLabel + } + } + ] + } + } + }); + }; + + service.CreateNetworkChart = function(context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'RX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + }, + { + label: 'TX on eth0', + data: [], + fill: false, + backgroundColor: 'rgba(255,180,174,0.5)', + borderColor: 'rgba(255,180,174,0.7)', + pointBackgroundColor: 'rgba(255,180,174,1)', + pointBorderColor: 'rgba(255,180,174,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: { + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: 'average', + callbacks: { + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; + return byteBasedTooltipLabel(datasetLabel, tooltipItem.yLabel); + } + } + }, + hover: { + animationDuration: 0 + }, + scales: { + yAxes: [{ + ticks: { + beginAtZero: true, + callback: byteBasedAxisLabel + } + }] + } + } + }); + }; + + service.UpdateMemoryChart = function(label, value, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); + }; + + service.UpdateCPUChart = function(label, value, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); + }; + + service.UpdateNetworkChart = function(label, rx, tx, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(rx); + chart.data.datasets[1].data.push(tx); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + chart.data.datasets[1].data.pop(); + } + + chart.update(0); + }; + + function byteBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 5) { + processedValue = filesize(value, {base: 10, round: 1}); + } else { + processedValue = value.toFixed(1) + 'B'; + } + return label + ': ' + processedValue; + } + + function byteBasedAxisLabel(value, index, values) { + if (value > 5) { + return filesize(value, {base: 10, round: 1}); + } + return value.toFixed(1) + 'B'; + } + + function percentageBasedAxisLabel(value, index, values) { + if (value > 1) { + return Math.round(value) + '%'; + } + return value.toFixed(1) + '%'; + } + + function percentageBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 1) { + processedValue = Math.round(value); + } else { + processedValue = value.toFixed(1); + } + return label + ': ' + processedValue + '%'; + } + + return service; +}]); diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index 001b2203d..c9f302d56 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -3,7 +3,22 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.getContainers = function (all) { + service.container = function(id) { + var deferred = $q.defer(); + + Container.get({ id: id }).$promise + .then(function success(data) { + var container = new ContainerDetailsViewModel(data); + deferred.resolve(container); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve container information', err: err }); + }); + + return deferred.promise; + }; + + service.containers = function(all) { var deferred = $q.defer(); Container.query({ all: all }).$promise .then(function success(data) { @@ -11,7 +26,7 @@ angular.module('portainer.services') deferred.resolve(containers); }) .catch(function error(err) { - deferred.reject({ msg: 'Unable to retriever containers', err: err }); + deferred.reject({ msg: 'Unable to retrieve containers', err: err }); }); return deferred.promise; }; @@ -105,5 +120,35 @@ angular.module('portainer.services') return deferred.promise; }; + service.containerStats = function(id) { + var deferred = $q.defer(); + + Container.stats({id: id}).$promise + .then(function success(data) { + var containerStats = new ContainerStatsViewModel(data); + deferred.resolve(containerStats); + }) + .catch(function error(err) { + deferred.reject(err); + }); + + return deferred.promise; + }; + + service.containerTop = function(id) { + var deferred = $q.defer(); + + Container.top({id: id}).$promise + .then(function success(data) { + var containerTop = data; + deferred.resolve(containerTop); + }) + .catch(function error(err) { + deferred.reject(err); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/services/docker/imageService.js b/app/services/docker/imageService.js index 95b0ba92e..181de81e3 100644 --- a/app/services/docker/imageService.js +++ b/app/services/docker/imageService.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('ImageService', ['$q', 'Image', 'ImageHelper', 'RegistryService', 'HttpRequestHelper', 'SystemService', function ImageServiceFactory($q, Image, ImageHelper, RegistryService, HttpRequestHelper, SystemService) { +.factory('ImageService', ['$q', 'Image', 'ImageHelper', 'RegistryService', 'HttpRequestHelper', 'ContainerService', function ImageServiceFactory($q, Image, ImageHelper, RegistryService, HttpRequestHelper, ContainerService) { 'use strict'; var service = {}; @@ -24,17 +24,23 @@ angular.module('portainer.services') var deferred = $q.defer(); $q.all({ - dataUsage: withUsage ? SystemService.dataUsage() : { Images: [] }, + containers: withUsage ? ContainerService.containers(1) : [], images: Image.query({}).$promise }) .then(function success(data) { - var images = data.images.map(function(item) { - item.dataUsage = data.dataUsage.Images.find(function(usage) { - return item.Id === usage.Id; - }); + var containers = data.containers; + var images = data.images.map(function(item) { + item.ContainerCount = 0; + for (var i = 0; i < containers.length; i++) { + var container = containers[i]; + if (container.ImageID === item.Id) { + item.ContainerCount++; + } + } return new ImageViewModel(item); }); + deferred.resolve(images); }) .catch(function error(err) { diff --git a/app/services/docker/networkService.js b/app/services/docker/networkService.js index 013311cf0..41f6961bf 100644 --- a/app/services/docker/networkService.js +++ b/app/services/docker/networkService.js @@ -3,6 +3,35 @@ angular.module('portainer.services') 'use strict'; var service = {}; + service.create = function(networkConfiguration) { + var deferred = $q.defer(); + + Network.create(networkConfiguration).$promise + .then(function success(data) { + deferred.resolve(data); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to create network', err: err }); + }); + return deferred.promise; + }; + + service.network = function(id) { + var deferred = $q.defer(); + + Network.get({id: id}).$promise + .then(function success(data) { + var network = new NetworkViewModel(data); + deferred.resolve(network); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve network details', err: err}); + }); + + return deferred.promise; + }; + + service.networks = function(localNetworks, swarmNetworks, swarmAttachableNetworks, globalNetworks) { var deferred = $q.defer(); @@ -23,6 +52,8 @@ angular.module('portainer.services') if (globalNetworks && network.Scope === 'global') { return network; } + }).map(function (item) { + return new NetworkViewModel(item); }); deferred.resolve(filteredNetworks); diff --git a/app/services/docker/nodeService.js b/app/services/docker/nodeService.js index 2fb394a58..6ccafb318 100644 --- a/app/services/docker/nodeService.js +++ b/app/services/docker/nodeService.js @@ -3,7 +3,7 @@ angular.module('portainer.services') 'use strict'; var service = {}; - service.nodes = function(id) { + service.nodes = function() { var deferred = $q.defer(); Node.query({}).$promise diff --git a/app/services/docker/pluginService.js b/app/services/docker/pluginService.js index d6e3325e6..690010d1a 100644 --- a/app/services/docker/pluginService.js +++ b/app/services/docker/pluginService.js @@ -52,5 +52,37 @@ angular.module('portainer.services') return deferred.promise; }; + service.networkPlugins = function(systemOnly) { + var deferred = $q.defer(); + + $q.all({ + system: SystemService.plugins(), + plugins: systemOnly ? [] : service.plugins() + }) + .then(function success(data) { + var networkPlugins = []; + var systemPlugins = data.system; + var plugins = data.plugins; + + if (systemPlugins.Network) { + networkPlugins = networkPlugins.concat(systemPlugins.Network); + } + + for (var i = 0; i < plugins.length; i++) { + var plugin = plugins[i]; + if (plugin.Enabled && _.includes(plugin.Config.Interface.Types, 'docker.networkdriver/1.0')) { + networkPlugins.push(plugin.Name); + } + } + + deferred.resolve(networkPlugins); + }) + .catch(function error(err) { + deferred.reject({ msg: err.msg, err: err }); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/services/docker/serviceService.js b/app/services/docker/serviceService.js index 939f1875e..0020c3412 100644 --- a/app/services/docker/serviceService.js +++ b/app/services/docker/serviceService.js @@ -3,6 +3,23 @@ angular.module('portainer.services') 'use strict'; var service = {}; + service.services = function() { + var deferred = $q.defer(); + + Service.query().$promise + .then(function success(data) { + var services = data.map(function (item) { + return new ServiceViewModel(item); + }); + deferred.resolve(services); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve services', err: err }); + }); + + return deferred.promise; + }; + service.service = function(id) { var deferred = $q.defer(); diff --git a/app/services/docker/taskService.js b/app/services/docker/taskService.js index 55b9b4f67..44dab1922 100644 --- a/app/services/docker/taskService.js +++ b/app/services/docker/taskService.js @@ -3,6 +3,23 @@ angular.module('portainer.services') 'use strict'; var service = {}; + service.tasks = function() { + var deferred = $q.defer(); + + Task.query().$promise + .then(function success(data) { + var tasks = data.map(function (item) { + return new TaskViewModel(item); + }); + deferred.resolve(tasks); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve tasks', err: err }); + }); + + return deferred.promise; + }; + service.task = function(id) { var deferred = $q.defer(); diff --git a/app/services/localStorage.js b/app/services/localStorage.js index ae991ddea..89cd98483 100644 --- a/app/services/localStorage.js +++ b/app/services/localStorage.js @@ -43,6 +43,16 @@ angular.module('portainer.services') }, clean: function() { localStorageService.clearAll(); + }, + storeFilterContainerShowAll: function(filter) { + localStorageService.cookie.set('filter_containerShowAll', filter); + }, + getFilterContainerShowAll: function() { + var filter = localStorageService.cookie.get('filter_containerShowAll'); + if (filter === null) { + filter = true; + } + return filter; } }; }]); diff --git a/app/services/notifications.js b/app/services/notifications.js index af2be044d..3679cba18 100644 --- a/app/services/notifications.js +++ b/app/services/notifications.js @@ -13,6 +13,8 @@ angular.module('portainer.services') msg = e.data.message; } else if (e.message) { msg = e.message; + } else if (e.err && e.err.data && e.err.data.message) { + msg = e.err.data.message; } else if (e.data && e.data.length > 0 && e.data[0].message) { msg = e.data[0].message; } else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) { @@ -22,7 +24,9 @@ angular.module('portainer.services') } else if (e.data && e.data.err) { msg = e.data.err; } - toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000}); + if (msg !== 'Invalid JWT token') { + toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000}); + } }; return service; diff --git a/assets/css/app.css b/assets/css/app.css index 2b2f4bb80..5a4d2d88a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -254,11 +254,11 @@ a[ng-click]{ margin-bottom: 10px; } -.login-form > div { +.simple-box-form > div { margin-bottom: 25px; } -.login-form > div:last-child { +.simple-box-form > div:last-child { margin-top: 10px; margin-bottom: 10px; } @@ -474,6 +474,88 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } } +.visualizer_container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-around; +} + +.visualizer_container .node { + border: 1px dashed #337ab7; + background-color: rgb(51, 122, 183); + background-color: rgba(51, 122, 183, 0.1); + border-radius: 4px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + padding: 15px; + margin: 5px; +} + +.visualizer_container .node .node_info { + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + border-bottom: 1px solid #777; + padding-bottom: 10px; +} + +.visualizer_container .node .node_info .node_platform { + margin-left: 2px; + font-size: 16px; +} + +.visualizer_container .node .tasks { + display: flex; + flex-direction: column; + margin-top: 5px; +} + +.visualizer_container .node .tasks .task { + border: 1px solid #333333; + border-radius: 2px; + box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + padding: 10px; + margin: 5px; +} + +.visualizer_container .node .tasks .task div { + padding: 2px; +} + +.visualizer_container .node .tasks .task_running { + border: 2px solid #23ae89; + border-radius: 4px; + background-color: rgb(35, 174, 137); + background-color: rgba(35, 174, 137, 0.2); +} + +.visualizer_container .node .tasks .task_stopped { + border: 2px solid #ae2323; + border-radius: 4px; + background-color: rgb(174, 35, 35); + background-color: rgba(174, 35, 35, 0.2); +} + +.visualizer_container .node .tasks .task_warning { + border: 2px solid #f0ad4e; + border-radius: 4px; + background-color: rgb(240, 173, 78); + background-color: rgba(240, 173, 78, 0.2); +} + +.visualizer_container .node .tasks .task_info { + border: 2px solid #46b8da; + border-radius: 4px; + background-color: rgb(70, 184, 218); + background-color: rgba(70, 184, 218, 0.2); +} + +.visualizer_container .node .tasks .task .service_name { + text-align: center; + margin-bottom: 5px; +} + /*bootbox override*/ .modal-open { padding-right: 0 !important; diff --git a/assets/ico/android-chrome-192x192.png b/assets/ico/android-chrome-192x192.png new file mode 100644 index 000000000..abf81c516 Binary files /dev/null and b/assets/ico/android-chrome-192x192.png differ diff --git a/assets/ico/android-chrome-256x256.png b/assets/ico/android-chrome-256x256.png new file mode 100644 index 000000000..8770d7d3b Binary files /dev/null and b/assets/ico/android-chrome-256x256.png differ diff --git a/assets/ico/apple-touch-icon-precomposed.png b/assets/ico/apple-touch-icon-precomposed.png deleted file mode 100644 index 4da47e22c..000000000 Binary files a/assets/ico/apple-touch-icon-precomposed.png and /dev/null differ diff --git a/assets/ico/apple-touch-icon.png b/assets/ico/apple-touch-icon.png new file mode 100644 index 000000000..6ca3086cd Binary files /dev/null and b/assets/ico/apple-touch-icon.png differ diff --git a/assets/ico/browserconfig.xml b/assets/ico/browserconfig.xml new file mode 100644 index 000000000..f9aefe5b5 --- /dev/null +++ b/assets/ico/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #2d89ef + + + diff --git a/assets/ico/favicon-16x16.png b/assets/ico/favicon-16x16.png new file mode 100644 index 000000000..192339c24 Binary files /dev/null and b/assets/ico/favicon-16x16.png differ diff --git a/assets/ico/favicon-32x32.png b/assets/ico/favicon-32x32.png new file mode 100644 index 000000000..25c2aeeb1 Binary files /dev/null and b/assets/ico/favicon-32x32.png differ diff --git a/assets/ico/favicon.ico b/assets/ico/favicon.ico index 942ba394b..512d8b51b 100644 Binary files a/assets/ico/favicon.ico and b/assets/ico/favicon.ico differ diff --git a/assets/ico/manifest.json b/assets/ico/manifest.json new file mode 100644 index 000000000..e753aeb6b --- /dev/null +++ b/assets/ico/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Portainer", + "icons": [ + { + "src": "/ico/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/ico/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/assets/ico/mstile-150x150.png b/assets/ico/mstile-150x150.png new file mode 100644 index 000000000..f3670168a Binary files /dev/null and b/assets/ico/mstile-150x150.png differ diff --git a/assets/ico/safari-pinned-tab.svg b/assets/ico/safari-pinned-tab.svg new file mode 100644 index 000000000..79ce7b6fa --- /dev/null +++ b/assets/ico/safari-pinned-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/logo_ico.png b/assets/images/logo_ico.png new file mode 100644 index 000000000..c233495c2 Binary files /dev/null and b/assets/images/logo_ico.png differ diff --git a/assets/js/legend.js b/assets/js/legend.js deleted file mode 100644 index 7b7933a46..000000000 --- a/assets/js/legend.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * legend.js v0.2.0 - * License: MIT - */ -function legend(parent, data) { - parent.className = 'legend'; - var datas = data.hasOwnProperty('datasets') ? data.datasets : data; - - datas.forEach(function(d) { - var title = document.createElement('span'); - title.className = 'title'; - title.style.borderColor = d.hasOwnProperty('strokeColor') ? d.strokeColor : d.color; - title.style.borderStyle = 'solid'; - parent.appendChild(title); - - var text = document.createTextNode(d.title); - title.appendChild(text); - }); -} diff --git a/bower.json b/bower.json index a61ae1b8c..764a9fb4d 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.14.0", + "version": "1.14.1", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " @@ -24,7 +24,6 @@ "tests" ], "dependencies": { - "Chart.js": "1.0.2", "angular": "~1.5.0", "angular-cookies": "~1.5.0", "angular-bootstrap": "~2.5.0", @@ -49,7 +48,9 @@ "bootbox.js": "bootbox#^4.4.0", "angular-multi-select": "~4.0.0", "toastr": "~2.1.3", - "xterm.js": "~2.8.1" + "xterm.js": "~2.8.1", + "chart.js": "~2.6.0", + "angularjs-slider": "^6.4.0" }, "resolutions": { "angular": "1.5.11" diff --git a/index.html b/index.html index 3219c5fbd..f120e0164 100644 --- a/index.html +++ b/index.html @@ -24,13 +24,22 @@ - - + + + + + + + + -
    - +
    diff --git a/package.json b/package.json index 8c7dec2da..c1ea2d995 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.14.0", + "version": "1.14.1", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" diff --git a/vendor.yml b/vendor.yml index 4c1e83937..949b6f1ce 100644 --- a/vendor.yml +++ b/vendor.yml @@ -5,15 +5,15 @@ js: - bower_components/bootstrap/dist/js/bootstrap.js - bower_components/angular-multi-select/isteven-multi-select.js - bower_components/bootbox.js/bootbox.js - - bower_components/Chart.js/Chart.js - bower_components/filesize/lib/filesize.js - bower_components/lodash/dist/lodash.js - bower_components/moment/moment.js + - bower_components/chart.js/dist/Chart.js - bower_components/splitargs/src/splitargs.js - bower_components/toastr/toastr.js - bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/addons/fit/fit.js - - assets/js/legend.js + - bower_components/angularjs-slider/dist/rzslider.js minified: - bower_components/jquery/dist/jquery.min.js - bower_components/bootstrap/dist/js/bootstrap.min.js @@ -23,11 +23,12 @@ js: - bower_components/filesize/lib/filesize.min.js - bower_components/lodash/dist/lodash.min.js - bower_components/moment/min/moment.min.js + - bower_components/chart.js/dist/Chart.min.js - bower_components/splitargs/src/splitargs.js - bower_components/toastr/toastr.min.js - bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/addons/fit/fit.js - - assets/js/legend.js + - bower_components/angularjs-slider/dist/rzslider.min.js css: regular: - bower_components/bootstrap/dist/css/bootstrap.css @@ -37,6 +38,7 @@ css: - bower_components/font-awesome/css/font-awesome.css - bower_components/toastr/toastr.css - bower_components/xterm.js/dist/xterm.css + - bower_components/angularjs-slider/dist/rzslider.css minified: - bower_components/bootstrap/dist/css/bootstrap.min.css - bower_components/rdash-ui/dist/css/rdash.min.css @@ -45,6 +47,7 @@ css: - bower_components/font-awesome/css/font-awesome.min.css - bower_components/toastr/toastr.min.css - bower_components/xterm.js/dist/xterm.css + - bower_components/angularjs-slider/dist/rzslider.min.css angular: regular: - bower_components/angular/angular.js @@ -71,4 +74,4 @@ angular: - bower_components/angular-ui-select/dist/select.min.js - bower_components/angular-ui-router/release/angular-ui-router.min.js - bower_components/angular-utils-pagination/dirPagination.js - - bower_components/ng-file-upload/ng-file-upload.min.js \ No newline at end of file + - bower_components/ng-file-upload/ng-file-upload.min.js