Merge branch 'release/1.14.0'

pull/1136/head 1.14.0
Anthony Lapenna 2017-08-13 20:17:30 +02:00
commit 8e2dbd1775
124 changed files with 4453 additions and 738 deletions

View File

@ -7,6 +7,7 @@
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
[![Codefresh build status]( https://g.codefresh.io/api/badges/build?repoOwner=portainer&repoName=portainer&branch=develop&pipelineName=portainer-ci&accountName=deviantony&type=cf-1)]( https://g.codefresh.io/repositories/portainer/portainer/builds?filter=trigger:build;branch:develop;service:5922a08a3a1aab000116fcc6~portainer-ci)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)

View File

@ -0,0 +1,25 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateSettingsToVersion3() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.AuthenticationMethod = portainer.AuthenticationInternal
legacySettings.LDAPSettings = portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
}
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}

View File

@ -7,6 +7,7 @@ type Migrator struct {
UserService *UserService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
SettingsService *SettingsService
VersionService *VersionService
CurrentDBVersion int
store *Store
@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator {
UserService: store.UserService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
VersionService: store.VersionService,
CurrentDBVersion: version,
store: store,
@ -47,6 +49,14 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 1.13.x
if m.CurrentDBVersion == 2 {
err := m.updateSettingsToVersion3()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err

View File

@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/http"
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
"log"
)
@ -68,6 +69,10 @@ func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true
if externalEnpointFile != "" {
@ -113,6 +118,13 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayExternalContributors: true,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
},
}
if *flags.Templates != "" {
@ -155,6 +167,8 @@ func main() {
cryptoService := initCryptoService()
ldapService := initLDAPService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags)
@ -225,6 +239,7 @@ func main() {
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,

View File

@ -7,20 +7,28 @@ import (
)
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) {
config := &tls.Config{}
if certPath != "" && keyPath != "" {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{cert}
}
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
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
}

View File

@ -6,12 +6,13 @@ import (
"io"
"os"
"path"
"strconv"
)
const (
// TLSStorePath represents the subfolder where TLS files are stored in the file store folder.
TLSStorePath = "tls"
// LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath.
LDAPStorePath = "ldap"
// TLSCACertFile represents the name on disk for a TLS CA file.
TLSCACertFile = "ca.pem"
// TLSCertFile represents the name on disk for a TLS certificate file.
@ -50,11 +51,10 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return service, nil
}
// StoreTLSFile creates a subfolder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType, r io.Reader) error {
ID := strconv.Itoa(int(endpointID))
endpointStorePath := path.Join(TLSStorePath, ID)
err := service.createDirectoryInStoreIfNotExist(endpointStorePath)
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
storePath := path.Join(TLSStorePath, folder)
err := service.createDirectoryInStoreIfNotExist(storePath)
if err != nil {
return err
}
@ -71,7 +71,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p
return portainer.ErrUndefinedTLSFileType
}
tlsFilePath := path.Join(endpointStorePath, fileName)
tlsFilePath := path.Join(storePath, fileName)
err = service.createFileInStore(tlsFilePath, r)
if err != nil {
return err
@ -80,7 +80,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p
}
// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint.
func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType) (string, error) {
func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) {
var fileName string
switch fileType {
case portainer.TLSFileCA:
@ -92,15 +92,13 @@ func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileT
default:
return "", portainer.ErrUndefinedTLSFileType
}
ID := strconv.Itoa(int(endpointID))
return path.Join(service.fileStorePath, TLSStorePath, ID, fileName), nil
return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil
}
// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint.
func (service *Service) DeleteTLSFiles(endpointID portainer.EndpointID) error {
ID := strconv.Itoa(int(endpointID))
endpointPath := path.Join(service.fileStorePath, TLSStorePath, ID)
err := os.RemoveAll(endpointPath)
func (service *Service) DeleteTLSFiles(folder string) error {
storePath := path.Join(service.fileStorePath, TLSStorePath, folder)
err := os.RemoveAll(storePath)
if err != nil {
return err
}

View File

@ -4,7 +4,6 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
)
// errorResponse is a generic response for sending a error.
@ -21,10 +20,3 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.
w.WriteHeader(code)
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
}
// WriteMethodNotAllowedResponse writes an error message to the response and sets the Allow header.
func WriteMethodNotAllowedResponse(w http.ResponseWriter, allowedMethods []string) {
w.Header().Set("Allow", strings.Join(allowedMethods, ", "))
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)})
}

View File

@ -17,11 +17,13 @@ import (
// AuthHandler represents an HTTP API handler for managing authentication.
type AuthHandler struct {
*mux.Router
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SettingsService portainer.SettingsService
}
const (
@ -42,17 +44,23 @@ func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHa
authDisabled: authDisabled,
}
h.Handle("/auth",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
return h
}
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
type (
postAuthRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
postAuthResponse struct {
JWT string `json:"jwt"`
}
)
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
if handler.authDisabled {
httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger)
return
@ -82,17 +90,32 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
return
}
err = handler.CryptoService.CompareHashAndData(u.Password, password)
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
} else {
err = handler.CryptoService.CompareHashAndData(u.Password, password)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
return
}
}
tokenData := &portainer.TokenData{
ID: u.ID,
Username: u.Username,
Role: u.Role,
}
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@ -101,12 +124,3 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger)
}
type postAuthRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
type postAuthResponse struct {
JWT string `json:"jwt"`
}

View File

@ -30,7 +30,7 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.PathPrefix("/{id}/").Handler(
h.PathPrefix("/{id}/docker").Handler(
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI)))
return h
}
@ -90,5 +90,5 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
}
}
http.StripPrefix("/"+id, proxy).ServeHTTP(w, r)
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
}

View File

@ -22,20 +22,28 @@ type DockerHubHandler struct {
DockerHubService portainer.DockerHubService
}
// NewDockerHubHandler returns a new instance of OldDockerHubHandler.
// NewDockerHubHandler returns a new instance of NewDockerHubHandler.
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
h := &DockerHubHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/dockerhub",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
h.Handle("/dockerhub",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut)
return h
}
type (
putDockerHubRequest struct {
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetDockerHub handles GET requests on /dockerhub
func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) {
dockerhub, err := handler.DockerHubService.DockerHub()
@ -79,9 +87,3 @@ func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *ht
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
type putDockerHubRequest struct {
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}

View File

@ -55,6 +55,31 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
return h
}
type (
postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
}
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointsRequest struct {
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
TLS bool `valid:"-"`
}
)
// handleGetEndpoints handles GET requests on /endpoints
func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -113,11 +138,12 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
}
if req.TLS {
caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA)
folder := strconv.Itoa(int(endpoint.ID))
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert)
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey)
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
@ -129,17 +155,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
}
type postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
}
type postEndpointsResponse struct {
ID int `json:"Id"`
}
// handleGetEndpoint handles GET requests on /endpoints/:id
func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -218,11 +233,6 @@ func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r
}
}
type putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
// handlePutEndpoint handles PUT requests on /endpoints/:id
func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@ -272,20 +282,21 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLS = true
caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA)
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert)
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey)
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.TLSKeyPath = ""
err = handler.FileService.DeleteTLSFiles(endpoint.ID)
err = handler.FileService.DeleteTLSFiles(folder)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -305,13 +316,6 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
}
}
type putEndpointsRequest struct {
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
TLS bool `valid:"-"`
}
// handleDeleteEndpoint handles DELETE requests on /endpoints/:id
func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@ -347,7 +351,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
}
if endpoint.TLS {
err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID))
err = handler.FileService.DeleteTLSFiles(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return

View File

@ -36,46 +36,48 @@ const (
ErrInvalidRequestFormat = portainer.Error("Invalid request data format")
// ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid
ErrInvalidQueryFormat = portainer.Error("Invalid query format")
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
// ErrEmptyResponseBody = portainer.Error("Empty response body")
)
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/auth") {
switch {
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/users") {
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/teams") {
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/team_memberships") {
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/endpoints") {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/registries") {
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/dockerhub") {
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") {
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") {
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/registries"):
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/settings") {
case strings.HasPrefix(r.URL.Path, "/api/settings"):
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/status") {
case strings.HasPrefix(r.URL.Path, "/api/status"):
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/templates") {
case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/upload") {
case strings.HasPrefix(r.URL.Path, "/api/upload"):
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/websocket") {
case strings.HasPrefix(r.URL.Path, "/api/users"):
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/websocket"):
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/docker") {
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/") {
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}
}
// encodeJSON encodes v to w in JSON format. Error() is called if encoding fails.
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
if err := json.NewEncoder(w).Encode(v); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)

View File

@ -44,6 +44,33 @@ func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler {
return h
}
type (
postRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
postRegistriesResponse struct {
ID int `json:"Id"`
}
putRegistryAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetRegistries handles GET requests on /registries
func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -112,18 +139,6 @@ func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *h
encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger)
}
type postRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
type postRegistriesResponse struct {
ID int `json:"Id"`
}
// handleGetRegistry handles GET requests on /registries/:id
func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -202,11 +217,6 @@ func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r
}
}
type putRegistryAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
// handlePutRegistry handles PUT requests on /registries/:id
func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -276,14 +286,6 @@ func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http
}
}
type putRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
// handleDeleteRegistry handles DELETE requests on /registries/:id
func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)

View File

@ -39,6 +39,23 @@ func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler {
return h
}
type (
postResourcesRequest struct {
ResourceID string `valid:"required"`
Type string `valid:"required"`
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
SubResourceIDs []string `valid:"-"`
}
putResourcesRequest struct {
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
}
)
// handlePostResources handles POST requests on /resources
func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) {
var req postResourcesRequest
@ -121,22 +138,13 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
err = handler.ResourceControlService.CreateResourceControl(&resourceControl)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
return
}
type postResourcesRequest struct {
ResourceID string `valid:"required"`
Type string `valid:"required"`
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
SubResourceIDs []string `valid:"-"`
}
// handlePutResources handles PUT requests on /resources/:id
func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -210,12 +218,6 @@ func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *htt
}
}
type putResourcesRequest struct {
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
}
// handleDeleteResources handles DELETE requests on /resources/:id
func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)

View File

@ -5,6 +5,7 @@ import (
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
@ -20,6 +21,8 @@ type SettingsHandler struct {
*mux.Router
Logger *log.Logger
SettingsService portainer.SettingsService
LDAPService portainer.LDAPService
FileService portainer.FileService
}
// NewSettingsHandler returns a new instance of OldSettingsHandler.
@ -29,13 +32,38 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/settings",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
h.Handle("/settings",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut)
h.Handle("/settings/public",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet)
h.Handle("/settings/authentication/checkLDAP",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut)
return h
}
type (
publicSettingsResponse struct {
LogoURL string `json:"LogoURL"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
}
putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
AuthenticationMethod int `valid:"required"`
LDAPSettings portainer.LDAPSettings `valid:""`
}
putSettingsLDAPCheckRequest struct {
LDAPSettings portainer.LDAPSettings `valid:""`
}
)
// handleGetSettings handles GET requests on /settings
func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
@ -48,6 +76,24 @@ func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http
return
}
// handleGetPublicSettings handles GET requests on /settings/public
func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL,
DisplayExternalContributors: settings.DisplayExternalContributors,
AuthenticationMethod: settings.AuthenticationMethod,
}
encodeJSON(w, publicSettings, handler.Logger)
return
}
// handlePutSettings handles PUT requests on /settings
func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var req putSettingsRequest
@ -67,6 +113,27 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
LogoURL: req.LogoURL,
BlackListedLabels: req.BlackListedLabels,
DisplayExternalContributors: req.DisplayExternalContributors,
LDAPSettings: req.LDAPSettings,
}
if req.AuthenticationMethod == 1 {
settings.AuthenticationMethod = portainer.AuthenticationInternal
} else if req.AuthenticationMethod == 2 {
settings.AuthenticationMethod = portainer.AuthenticationLDAP
} else {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
} else {
settings.LDAPSettings.TLSConfig.TLSCACertPath = ""
err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
err = handler.SettingsService.StoreSettings(settings)
@ -75,9 +142,28 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
}
}
type putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check
func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) {
var req putSettingsLDAPCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
}
err = handler.LDAPService.TestConnectivity(&req.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@ -34,7 +34,7 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
h.Handle("/teams",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost)
h.Handle("/teams",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
@ -47,6 +47,20 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
return h
}
type (
postTeamsRequest struct {
Name string `valid:"required"`
}
postTeamsResponse struct {
ID int `json:"Id"`
}
putTeamRequest struct {
Name string `valid:"-"`
}
)
// handlePostTeams handles POST requests on /teams
func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) {
var req postTeamsRequest
@ -84,23 +98,23 @@ func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Reque
encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger)
}
type postTeamsResponse struct {
ID int `json:"Id"`
}
type postTeamsRequest struct {
Name string `valid:"required"`
}
// handleGetTeams handles GET requests on /teams
func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
teams, err := handler.TeamService.Teams()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, teams, handler.Logger)
filteredTeams := security.FilterUserTeams(teams, securityContext)
encodeJSON(w, filteredTeams, handler.Logger)
}
// handleGetTeam handles GET requests on /teams/:id
@ -181,10 +195,6 @@ func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request
}
}
type putTeamRequest struct {
Name string `valid:"-"`
}
// handleDeleteTeam handles DELETE requests on /teams/:id
func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)

View File

@ -42,6 +42,24 @@ func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipH
return h
}
type (
postTeamMembershipsRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
postTeamMembershipsResponse struct {
ID int `json:"Id"`
}
putTeamMembershipRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
)
// handlePostTeamMemberships handles POST requests on /team_memberships
func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -100,16 +118,6 @@ func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseW
encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger)
}
type postTeamMembershipsResponse struct {
ID int `json:"Id"`
}
type postTeamMembershipsRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
// handleGetTeamsMemberships handles GET requests on /team_memberships
func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -195,12 +203,6 @@ func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWri
}
}
type putTeamMembershipRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id
func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)

View File

@ -20,7 +20,7 @@ type TemplatesHandler struct {
}
const (
containerTemplatesURLLinuxServerIo = "http://tools.linuxserver.io/portainer.json"
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
)
// NewTemplatesHandler returns a new instance of TemplatesHandler.
@ -30,17 +30,12 @@ func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler {
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/templates",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates)))
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet)
return h
}
// handleGetTemplates handles GET requests on /templates?key=<key>
func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet})
return
}
key := r.FormValue("key")
if key == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)

View File

@ -8,7 +8,6 @@ import (
"log"
"net/http"
"os"
"strconv"
"github.com/gorilla/mux"
)
@ -26,23 +25,19 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUploadTLS)))
h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost)
return h
}
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
vars := mux.Vars(r)
endpointID := vars["endpointID"]
certificate := vars["certificate"]
ID, err := strconv.Atoi(endpointID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
folder := r.FormValue("folder")
if folder == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
@ -66,7 +61,7 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http
return
}
err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file)
err = handler.FileService.StoreTLSFile(folder, fileType, file)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return

View File

@ -26,6 +26,7 @@ type UserHandler struct {
TeamMembershipService portainer.TeamMembershipService
ResourceControlService portainer.ResourceControlService
CryptoService portainer.CryptoService
SettingsService portainer.SettingsService
}
// NewUserHandler returns a new instance of UserHandler.
@ -46,18 +47,45 @@ func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler {
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete)
h.Handle("/users/{id}/memberships",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
h.Handle("/users/{id}/teams",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
h.Handle("/users/{id}/passwd",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd)))
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost)
h.Handle("/users/admin/check",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck)))
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet)
h.Handle("/users/admin/init",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit)))
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost)
return h
}
type (
postUsersRequest struct {
Username string `valid:"required"`
Password string `valid:""`
Role int `valid:"required"`
}
postUsersResponse struct {
ID int `json:"Id"`
}
postUserPasswdRequest struct {
Password string `valid:"required"`
}
postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
putUserRequest struct {
Password string `valid:"-"`
Role int `valid:"-"`
}
postAdminInitRequest struct {
Password string `valid:"required"`
}
)
// handlePostUsers handles POST requests on /users
func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) {
var req postUsersRequest
@ -93,13 +121,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user, err := handler.UserService.UserByUsername(req.Username)
if err != nil && err != portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@ -110,16 +131,32 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user = &portainer.User{
Username: req.Username,
Role: role,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
}
err = handler.UserService.CreateUser(user)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@ -129,16 +166,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger)
}
type postUsersResponse struct {
ID int `json:"Id"`
}
type postUsersRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
Role int `valid:"required"`
}
// handleGetUsers handles GET requests on /users
func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -164,11 +191,6 @@ func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Reques
// handlePostUserPasswd handles POST requests on /users/:id/passwd
func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
vars := mux.Vars(r)
id := vars["id"]
@ -210,14 +232,6 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.
encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger)
}
type postUserPasswdRequest struct {
Password string `valid:"required"`
}
type postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
// handleGetUser handles GET requests on /users/:id
func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -317,18 +331,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request
}
}
type putUserRequest struct {
Password string `valid:"-"`
Role int `valid:"-"`
}
// handlePostAdminInit handles GET requests on /users/admin/check
// handleGetAdminCheck handles GET requests on /users/admin/check
func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet})
return
}
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@ -342,11 +346,6 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R
// handlePostAdminInit handles POST requests on /users/admin/init
func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
var req postAdminInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
@ -381,15 +380,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
return
}
if user != nil {
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger)
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger)
return
}
}
type postAdminInitRequest struct {
Password string `valid:"required"`
}
// handleDeleteUser handles DELETE requests on /users/:id
func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -454,37 +449,3 @@ func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.
encodeJSON(w, memberships, handler.Logger)
}
// handleGetTeams handles GET requests on /users/:id/teams
func (handler *UserHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
uid, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
userID := portainer.UserID(uid)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedUserManagement(userID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
teams, err := handler.TeamService.Teams()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredTeams := security.FilterUserTeams(teams, securityContext)
encodeJSON(w, filteredTeams, handler.Logger)
}

View File

@ -72,9 +72,7 @@ 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)
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return

View File

@ -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)
config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
if err != nil {
return nil, err
}

View File

@ -50,7 +50,7 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler
return h
}
// RestrictedAccess defines defines a security check for restricted endpoints.
// RestrictedAccess defines a security check for restricted endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to authorize/filter access to resources.

View File

@ -27,6 +27,7 @@ type Server struct {
FileService portainer.FileService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
LDAPService portainer.LDAPService
Handler *handler.Handler
SSL bool
SSLCert string
@ -42,12 +43,15 @@ func (server *Server) Start() error {
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService
authHandler.SettingsService = server.SettingsService
var userHandler = handler.NewUserHandler(requestBouncer)
userHandler.UserService = server.UserService
userHandler.TeamService = server.TeamService
userHandler.TeamMembershipService = server.TeamMembershipService
userHandler.CryptoService = server.CryptoService
userHandler.ResourceControlService = server.ResourceControlService
userHandler.SettingsService = server.SettingsService
var teamHandler = handler.NewTeamHandler(requestBouncer)
teamHandler.TeamService = server.TeamService
teamHandler.TeamMembershipService = server.TeamMembershipService
@ -56,6 +60,8 @@ func (server *Server) Start() error {
var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status)
var settingsHandler = handler.NewSettingsHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.FileService = server.FileService
var templatesHandler = handler.NewTemplatesHandler(requestBouncer)
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)

123
api/ldap/ldap.go Normal file
View File

@ -0,0 +1,123 @@
package ldap
import (
"fmt"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"gopkg.in/ldap.v2"
)
const (
// ErrUserNotFound defines an error raised when the user is not found via LDAP search
// or that too many entries (> 1) are returned.
ErrUserNotFound = portainer.Error("User not found or too many entries returned")
)
// Service represents a service used to authenticate users against a LDAP/AD.
type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username),
[]string{"dn"},
nil,
)
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, _ := conn.Search(searchRequest)
if len(sr.Entries) == 1 {
found = true
userDN = sr.Entries[0].DN
break
}
}
if !found {
return "", ErrUserNotFound
}
return userDN, nil
}
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
config.ServerName = strings.Split(settings.URL, ":")[0]
if settings.TLSConfig.TLS {
return ldap.DialTLS("tcp", settings.URL, config)
}
conn, err := ldap.Dial("tcp", settings.URL)
if err != nil {
return nil, err
}
err = conn.StartTLS(config)
if err != nil {
return nil, err
}
return conn, nil
}
return ldap.Dial("tcp", settings.URL)
}
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
func (*Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil {
return err
}
err = connection.Bind(userDN, password)
if err != nil {
return err
}
return nil
}
// TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
return nil
}

View File

@ -41,12 +41,40 @@ type (
Version string `json:"Version"`
}
// LDAPSettings represents the settings used to connect to a LDAP server.
LDAPSettings struct {
ReaderDN string `json:"ReaderDN"`
Password string `json:"Password"`
URL string `json:"URL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"`
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
}
// TLSConfiguration represents a TLS configuration.
TLSConfiguration struct {
TLS bool `json:"TLS"`
TLSSkipVerify bool `json:"TLSSkipVerify"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// LDAPSearchSettings represents settings used to search for users in a LDAP server.
LDAPSearchSettings struct {
BaseDN string `json:"BaseDN"`
Filter string `json:"Filter"`
UserNameAttribute string `json:"UserNameAttribute"`
}
// Settings represents the application settings.
Settings struct {
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"`
}
// User represents a user account.
@ -64,6 +92,9 @@ type (
// or a regular user
UserRole int
// AuthenticationMethod represents the authentication method used to authenticate a user.
AuthenticationMethod int
// Team represents a list of user accounts.
Team struct {
ID TeamID `json:"Id"`
@ -292,22 +323,28 @@ type (
// FileService represents a service for managing files.
FileService interface {
StoreTLSFile(endpointID EndpointID, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error)
DeleteTLSFiles(endpointID EndpointID) error
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
DeleteTLSFiles(folder string) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
EndpointWatcher interface {
WatchEndpointFile(endpointFilePath string) error
}
// LDAPService represents a service used to authenticate users against a LDAP/AD.
LDAPService interface {
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
}
)
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.13.6"
APIVersion = "1.14.0"
// DBVersion is the version number of the Portainer database.
DBVersion = 2
DBVersion = 3
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)
@ -337,6 +374,14 @@ const (
StandardUserRole
)
const (
_ AuthenticationMethod = iota
// AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
AuthenticationInternal
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
AuthenticationLDAP
)
const (
_ ResourceAccessLevel = iota
// ReadWriteAccessLevel represents an access level with read-write permissions on a resource

2471
api/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -51,6 +51,7 @@ angular.module('portainer', [
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'stats',
'swarm',
@ -243,7 +244,7 @@ angular.module('portainer', [
}
})
.state('actions.create.container', {
url: '/container',
url: '/container/:from',
views: {
'content@': {
templateUrl: 'app/components/createContainer/createcontainer.html',
@ -563,6 +564,19 @@ angular.module('portainer', [
}
}
})
.state('settings_authentication', {
url: '^/settings/authentication',
views: {
'content@': {
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
controller: 'SettingsAuthenticationController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('task', {
url: '^/task/:id',
views: {
@ -744,18 +758,16 @@ angular.module('portainer', [
// This is your docker url that the api will use to make requests
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
.constant('DOCKER_ENDPOINT', 'api/docker')
.constant('CONFIG_ENDPOINT', 'api/old_settings')
.constant('SETTINGS_ENDPOINT', 'api/settings')
.constant('STATUS_ENDPOINT', 'api/status')
.constant('AUTH_ENDPOINT', 'api/auth')
.constant('USERS_ENDPOINT', 'api/users')
.constant('TEAMS_ENDPOINT', 'api/teams')
.constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships')
.constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls')
.constant('ENDPOINTS_ENDPOINT', 'api/endpoints')
.constant('DOCKERHUB_ENDPOINT', 'api/dockerhub')
.constant('REGISTRIES_ENDPOINT', 'api/registries')
.constant('TEMPLATES_ENDPOINT', 'api/templates')
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10);

View File

@ -20,6 +20,8 @@
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button class="btn btn-danger" ng-click="recreate()"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
</div>
</rd-widget-body>
</rd-widget>
@ -94,6 +96,7 @@
<!-- access-control-panel -->
<por-access-control-panel
ng-if="container && applicationState.application.authentication"
resource-id="container.Id"
resource-control="container.ResourceControl"
resource-type="'container'">
</por-access-control-panel>
@ -261,7 +264,7 @@
</div>
</div>
<div class="row" ng-if="!(container.NetworkSettings.Networks | emptyobject)">
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title="Connected networks">
@ -295,6 +298,9 @@
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(container, value.NetworkID)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Leave Network</button>
</td>
</tr>
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
<td colspan="5" class="text-center text-muted">No networks connected.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">

View File

@ -1,6 +1,6 @@
angular.module('container', [])
.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService',
function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, Notifications, Pagination, ModalService) {
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
$scope.config = {
@ -196,6 +196,88 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
});
};
$scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
});
};
$scope.confirmRemove = function () {
var title = 'You are about to remove a container.';
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
var cleanAssociatedVolumes = false;
if (result[0]) {
cleanAssociatedVolumes = true;
}
$scope.remove(cleanAssociatedVolumes);
}
);
};
function recreateContainer(pullImage) {
$('#loadingViewSpinner').show();
var container = $scope.container;
var config = ContainerHelper.configFromContainer(container.Model);
ContainerService.remove(container, true)
.then(function success() {
return RegistryService.retrieveRegistryFromRepository(container.Config.Image);
})
.then(function success(data) {
return $q.when(!pullImage || ImageService.pullImage(container.Config.Image, data, true));
})
.then(function success() {
return ContainerService.createAndStartContainer(config);
})
.then(function success(data) {
if (!container.ResourceControl) {
return true;
} else {
var containerIdentifier = data.Id;
var resourceControl = container.ResourceControl;
var users = resourceControl.UserAccesses.map(function(u) {
return u.UserId;
});
var teams = resourceControl.TeamAccesses.map(function(t) {
return t.TeamId;
});
return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly,
users, teams, containerIdentifier, 'container', []);
}
})
.then(function success(data) {
Notifications.success('Container successfully re-created');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to re-create container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
$scope.recreate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
ModalService.confirmContainerRecreation(function (result) {
if(!result) { return; }
var pullImage = false;
if (result[0]) {
pullImage = true;
}
recreateContainer(pullImage);
});
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$('#joinNetworkSpinner').show();
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {
@ -213,25 +295,21 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
});
};
Network.query({}, function (d) {
var networks = d;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
networks = d.filter(function (network) {
if (network.Scope === 'global') {
return network;
}
});
networks.push({Name: 'bridge'});
networks.push({Name: 'host'});
networks.push({Name: 'none'});
}
$scope.availableNetworks = networks;
if (!_.find(networks, {'Name': 'bridge'})) {
networks.push({Name: 'nat'});
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve networks');
});
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM'
)
.then(function success(data) {
var networks = data;
$scope.availableNetworks = networks;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
update();
}]);

View File

@ -38,7 +38,7 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
$scope.connect = function() {
$('#loadConsoleSpinner').show();
var termWidth = Math.round($('#terminal-container').width() / 8.2);
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
var termHeight = 30;
var command = $scope.formValues.isCustomCommand ?
$scope.formValues.customCommand : $scope.formValues.command;
@ -97,6 +97,11 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
term.open(document.getElementById('terminal-container'), true);
term.resize(width, height);
term.setOption('cursorBlink', true);
term.fit();
window.onresize = function() {
term.fit();
};
socket.onmessage = function (e) {
term.write(e.data);

View File

@ -205,7 +205,8 @@ 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') {
$scope.state.noRunningItemsSelected = false;

View File

@ -1,14 +1,13 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', [])
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator) {
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
$scope.formValues = {
alwaysPull: true,
Console: 'none',
Volumes: [],
Registry: '',
NetworkContainer: '',
Labels: [],
ExtraHosts: [],
@ -92,6 +91,8 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
$scope.config.HostConfig.Devices.splice(index, 1);
};
$scope.fromContainerMultipleNetworks = false;
function prepareImageConfig(config) {
var image = config.Image;
var registry = $scope.formValues.Registry;
@ -179,6 +180,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
var networkMode = mode;
if (containerName) {
networkMode += ':' + containerName;
config.Hostname = '';
}
config.HostConfig.NetworkMode = networkMode;
@ -233,6 +235,213 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
return config;
}
function confirmCreateContainer() {
var deferred = $q.defer();
Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise
.then(function success(data) {
var existingContainer = data[0];
if (existingContainer) {
ModalService.confirm({
title: 'Are you sure ?',
message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
buttons: {
confirm: {
label: 'Replace',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
if(!confirmed) { deferred.resolve(false); }
else {
// Remove old container
ContainerService.remove(existingContainer, true)
.then(function success(data) {
Notifications.success('Container Removed', existingContainer.Id);
deferred.resolve(true);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove container', err: err });
});
}
}
});
} else {
deferred.resolve(true);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers');
return undefined;
});
return deferred.promise;
}
function loadFromContainerCmd(d) {
if ($scope.config.Cmd) {
$scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd);
} else {
$scope.config.Cmd = '';
}
}
function loadFromContainerPortBindings(d) {
var bindings = [];
for (var p in $scope.config.HostConfig.PortBindings) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) {
var hostPort = '';
if ($scope.config.HostConfig.PortBindings[p][0].HostIp) {
hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':';
}
hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort;
var b = {
'hostPort': hostPort,
'containerPort': p.split('/')[0],
'protocol': p.split('/')[1]
};
bindings.push(b);
}
}
$scope.config.HostConfig.PortBindings = bindings;
}
function loadFromContainerVolumes(d) {
for (var v in d.Mounts) {
if ({}.hasOwnProperty.call(d.Mounts, v)) {
var mount = d.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
$scope.formValues.Volumes.push(volume);
}
}
}
function loadFromContainerNetworkConfig(d) {
$scope.config.NetworkingConfig = {
EndpointsConfig: {}
};
var networkMode = d.HostConfig.NetworkMode;
if (networkMode === 'default') {
$scope.config.HostConfig.NetworkMode = 'bridge';
if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}
if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) {
var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1];
$scope.config.HostConfig.NetworkMode = 'container';
for (var c in $scope.runningContainers) {
if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) {
$scope.formValues.NetworkContainer = $scope.runningContainers[c];
}
}
}
$scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2;
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) {
$scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address;
}
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) {
$scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address;
}
}
}
$scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode];
// ExtraHosts
for (var h in $scope.config.HostConfig.ExtraHosts) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) {
$scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]});
$scope.config.HostConfig.ExtraHosts = [];
}
}
}
function loadFromContainerEnvrionmentVariables(d) {
var envArr = [];
for (var e in $scope.config.Env) {
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
var arr = $scope.config.Env[e].split(/\=(.+)/);
envArr.push({'name': arr[0], 'value': arr[1]});
}
}
$scope.config.Env = envArr;
}
function loadFromContainerLabels(d) {
for (var l in $scope.config.Labels) {
if ({}.hasOwnProperty.call($scope.config.Labels, l)) {
$scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]});
}
}
}
function loadFromContainerConsole(d) {
if ($scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'both';
} else if (!$scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'tty';
} else if ($scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'interactive';
} else if (!$scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'none';
}
}
function loadFromContainerDevices(d) {
var path = [];
for (var dev in $scope.config.HostConfig.Devices) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
var device = $scope.config.HostConfig.Devices[dev];
path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer});
}
}
$scope.config.HostConfig.Devices = path;
}
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) {
if (data) {
$scope.config.Image = imageInfo.image;
$scope.formValues.Registry = data;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrive registry');
});
}
function loadFromContainerSpec() {
// Get container
Container.get({ id: $stateParams.from }).$promise
.then(function success(d) {
var fromContainer = new ContainerDetailsViewModel(d);
if (!fromContainer.ResourceControl) {
$scope.formValues.AccessControlData.AccessControlEnabled = false;
}
$scope.fromContainer = fromContainer;
$scope.config = ContainerHelper.configFromContainer(fromContainer.Model);
loadFromContainerCmd(d);
loadFromContainerPortBindings(d);
loadFromContainerVolumes(d);
loadFromContainerNetworkConfig(d);
loadFromContainerEnvrionmentVariables(d);
loadFromContainerLabels(d);
loadFromContainerConsole(d);
loadFromContainerDevices(d);
loadFromContainerImageConfig(d);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container');
});
}
function initView() {
Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes;
@ -240,31 +449,36 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
Notifications.error('Failure', e, 'Unable to retrieve volumes');
});
Network.query({}, function (d) {
var networks = d;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
networks = d.filter(function (network) {
if (network.Scope === 'global') {
return network;
}
});
$scope.globalNetworkCount = networks.length;
networks.push({Name: 'bridge'});
networks.push({Name: 'host'});
networks.push({Name: 'none'});
}
networks.push({Name: 'container'});
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM'
)
.then(function success(data) {
var networks = data;
networks.push({ Name: 'container' });
$scope.availableNetworks = networks;
if (!_.find(networks, {'Name': 'bridge'})) {
if (_.find(networks, {'Name': 'nat'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve networks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
Container.query({}, function (d) {
var containers = d;
$scope.runningContainers = containers;
if ($stateParams.from !== '') {
loadFromContainerSpec();
} else {
$scope.fromContainer = {};
$scope.formValues.Registry = {};
}
}, function(e) {
Notifications.error('Failure', e, 'Unable to retrieve running containers');
});
@ -284,19 +498,27 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
}
$scope.create = function () {
$('#createContainerSpinner').show();
confirmCreateContainer()
.then(function success(confirm) {
if (!confirm) {
return false;
}
$('#createContainerSpinner').show();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
var config = prepareConfiguration();
createContainer(config, accessControlData);
var config = prepareConfiguration();
createContainer(config, accessControlData);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
});
};
function createContainer(config, accessControlData) {

View File

@ -23,7 +23,7 @@
</div>
<!-- image-and-registry -->
<div class="form-group">
<por-image-registry image="config.Image" registry="formValues.Registry"></por-image-registry>
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
</div>
<!-- !image-and-registry -->
<!-- always-pull -->
@ -98,7 +98,7 @@
</div>
<!-- !port-mapping -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
@ -110,6 +110,10 @@
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">This container is connected to multiple networks, only one network will be kept at creation time.</span>
</span>
</div>
</div>
<!-- !actions -->

View File

@ -25,6 +25,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
PlacementConstraints: [],
PlacementPreferences: [],
UpdateDelay: 0,
UpdateOrder: 'stop-first',
FailureAction: 'pause',
Secrets: [],
AccessControlData: new AccessControlFormData()
@ -199,7 +200,8 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
config.UpdateConfig = {
Parallelism: input.Parallelism || 0,
Delay: input.UpdateDelay || 0,
FailureAction: input.FailureAction
FailureAction: input.FailureAction,
Order: input.UpdateOrder
};
}
@ -302,10 +304,12 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
function initView() {
$('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion;
$q.all({
volumes: VolumeService.volumes(),
networks: NetworkService.retrieveSwarmNetworks(),
secrets: SecretService.secrets()
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
networks: NetworkService.networks(true, true, false, false)
})
.then(function success(data) {
$scope.availableVolumes = data.volumes;

View File

@ -101,7 +101,7 @@
</div>
<!-- !port-mapping -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
@ -132,7 +132,7 @@
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#update-config" data-toggle="tab">Update config</a></li>
<li class="interactive"><a data-target="#secrets" data-toggle="tab" ng-if="applicationState.endpoint.apiVersion >= 1.25">Secrets</a></li>
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
<li class="interactive"><a data-target="#placement" data-toggle="tab">Placement</a></li>
</ul>
<!-- tab-content -->
@ -377,12 +377,12 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- parallelism-input -->
<div class="form-group">
<label for="parallelism" class="col-sm-2 col-lg-1 control-label text-left">Parallelism</label>
<div class="col-sm-2">
<label for="parallelism" class="col-sm-3 col-lg-1 control-label text-left">Parallelism</label>
<div class="col-sm-4 col-lg-3">
<input type="number" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1">
</div>
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
<div class="col-sm-5">
<p class="small text-muted">
Maximum number of tasks to be updated simultaneously (0 to update all at once).
</p>
</div>
@ -390,12 +390,12 @@
<!-- !parallelism-input -->
<!-- delay-input -->
<div class="form-group">
<label for="update-delay" class="col-sm-2 col-lg-1 control-label text-left">Delay</label>
<div class="col-sm-2">
<label for="update-delay" class="col-sm-3 col-lg-1 control-label text-left">Delay</label>
<div class="col-sm-4 col-lg-3">
<input type="number" class="form-control" ng-model="formValues.UpdateDelay" id="update-delay" placeholder="e.g. 10">
</div>
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
<div class="col-sm-5">
<p class="small text-muted">
Amount of time between updates.
</p>
</div>
@ -403,15 +403,39 @@
<!-- !delay-input -->
<!-- failureAction-input -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Failure action</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label for="failure-action" class="col-sm-3 col-lg-1 control-label text-left">Failure action</label>
<div class="col-sm-4 col-lg-3">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'continue'">Continue</label>
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'pause'">Pause</label>
</div>
</div>
<div class="col-sm-5">
<p class="small text-muted">
Action taken on failure to start after update.
</p>
</div>
</div>
<!-- !failureAction-input -->
<!-- order-input -->
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.29">
<label for="update-order" class="col-sm-3 col-lg-1 control-label text-left">Order</label>
<div class="col-sm-4 col-lg-3">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.UpdateOrder" uib-btn-radio="'start-first'">start-first</label>
<label class="btn btn-primary" ng-model="formValues.UpdateOrder" uib-btn-radio="'stop-first'">stop-first</label>
</div>
</div>
<div class="col-sm-5">
<p class="small text-muted">
Operation order on failure.
</p>
</div>
</div>
<!-- !order-input -->
</form>
</div>
<!-- !tab-update-config -->

View File

@ -1,6 +1,6 @@
angular.module('createVolume', [])
.controller('CreateVolumeController', ['$scope', '$state', 'VolumeService', 'SystemService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator',
function ($scope, $state, VolumeService, SystemService, ResourceControlService, Authentication, Notifications, FormValidator) {
.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator',
function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) {
$scope.formValues = {
Driver: 'local',
@ -70,8 +70,10 @@ function ($scope, $state, VolumeService, SystemService, ResourceControlService,
function initView() {
$('#loadingViewSpinner').show();
if ($scope.applicationState.endpoint.mode.provider !== 'DOCKER_SWARM') {
SystemService.getVolumePlugins()
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (endpointProvider !== 'DOCKER_SWARM') {
PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC')
.then(function success(data) {
$scope.availableVolumeDrivers = data;
})

View File

@ -65,7 +65,7 @@
</div>
<!-- !driver-options -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">

View File

@ -1,6 +1,6 @@
angular.module('image', [])
.controller('ImageController', ['$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications',
function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) {
.controller('ImageController', ['$q', '$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications',
function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) {
$scope.formValues = {
Image: '',
Registry: ''
@ -109,11 +109,16 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService,
});
};
function retrieveImageDetails() {
function initView() {
$('#loadingViewSpinner').show();
ImageService.image($stateParams.id)
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all({
image: ImageService.image($stateParams.id),
history: endpointProvider !== 'VMWARE_VIC' ? ImageService.history($stateParams.id) : []
})
.then(function success(data) {
$scope.image = data;
$scope.image = data.image;
$scope.history = data.history;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve image details');
@ -122,19 +127,7 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService,
.finally(function final() {
$('#loadingViewSpinner').hide();
});
$('#loadingViewSpinner').show();
ImageService.history($stateParams.id)
.then(function success(data) {
$scope.history = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve image history');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
retrieveImageDetails();
initView();
}]);

View File

@ -70,7 +70,7 @@
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
<span class="btn-group btn-group-sm pull-right" style="margin-right: 20px;" ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'">
<span class="btn-group btn-group-sm pull-right" style="margin-right: 20px;" ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM' && applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="undefined">
All
</label>
@ -125,7 +125,11 @@
<td><input type="checkbox" ng-model="image.Checked" ng-change="selectItem(image)" /></td>
<td>
<a class="monospaced" ui-sref="image({id: image.Id})">{{ image.Id|truncate:20}}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::image.Containers === 0 && applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'">Unused</span></td>
<span style="margin-left: 10px;" class="label label-warning image-tag"
ng-if="::image.Containers === 0 && applicationState.endpoint.mode.provider !== 'DOCKER_SWARM' && applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
Unused
</span>
</td>
<td>
<span class="label label-primary image-tag" ng-repeat="tag in (image|repotags)">{{ tag }}</span>
</td>
@ -135,7 +139,7 @@
<tr ng-if="!images">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="images.length == 0">
<tr ng-if="state.filteredImages.length === 0">
<td colspan="5" class="text-center text-muted">No images available.</td>
</tr>
</tbody>

View File

@ -94,7 +94,8 @@ function ($scope, $state, ImageService, Notifications, Pagination, ModalService)
function fetchImages() {
$('#loadImagesSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
ImageService.images(endpointProvider !== 'DOCKER_SWARM')
var apiVersion = $scope.applicationState.endpoint.apiVersion;
ImageService.images(apiVersion >= 1.25 && endpointProvider !== 'DOCKER_SWARM' && endpointProvider !== 'VMWARE_VIC')
.then(function success(data) {
$scope.images = data;
})

View File

@ -67,7 +67,7 @@
</div>
<div class="row" ng-if="!(network.Containers | emptyobject)">
<div class="row" ng-if="containersInNetwork.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title="Containers in network"></rd-widget-header>

View File

@ -51,8 +51,9 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe
}
function getContainersInNetwork(network) {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (network.Containers) {
if ($scope.applicationState.endpoint.apiVersion < 1.24) {
if (apiVersion < 1.24) {
Container.query({}, function success(data) {
var containersInNetwork = data.filter(function filter(container) {
if (container.HostConfig.NetworkMode === network.Name) {
@ -81,12 +82,20 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe
function initView() {
$('#loadingViewSpinner').show();
Network.get({id: $stateParams.id}, function success(data) {
Network.get({id: $stateParams.id}).$promise
.then(function success(data) {
$scope.network = data;
getContainersInNetwork(data);
}, function error(err) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider !== 'VMWARE_VIC') {
getContainersInNetwork(data);
}
})
.catch(function error(err) {
$('#loadingViewSpinner').hide();
Notifications.error('Failure', err, 'Unable to retrieve network info');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}

View File

@ -29,7 +29,7 @@
<span class="small text-muted">Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster.</span>
</div>
</div>
<div class="form-group" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<div class="form-group" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<div class="col-sm-12">
<span class="small text-muted">Note: The network will be created using the bridge driver.</span>
</div>

View File

@ -1,4 +1,4 @@
<div ng-if="service.ServicePreferences && applicationState.endpoint.apiVersion >= 1.30" id="service-placement-preferences">
<div ng-if="service.ServicePreferences" id="service-placement-preferences">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Placement preferences">
<div class="nopadding">

View File

@ -47,18 +47,38 @@
</p>
</td>
</tr>
<tr ng-if="applicationState.endpoint.apiVersion >= 1.29">
<td>Order</td>
<td>
<div class="form-group">
<label class="radio-inline">
<input type="radio" name="updateconfig_order" ng-model="service.UpdateOrder" value="start-first" ng-change="updateServiceAttribute(service, 'UpdateOrder')" ng-disabled="isUpdating">
start-first
</label>
<label class="radio-inline">
<input type="radio" name="updateconfig_order" ng-model="service.UpdateOrder" value="stop-first" ng-change="updateServiceAttribute(service, 'UpdateOrder')" ng-disabled="isUpdating">
stop-first
</label>
</div>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Operation order on failure.
</p>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>

View File

@ -113,11 +113,11 @@
<li><a href ng-click="goToItem('service-network-specs')">Network &amp; published ports</a></li>
<li><a href ng-click="goToItem('service-resources')">Resource limits &amp; reservations</a></li>
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
<li><a href ng-click="goToItem('service-placement-preferences')" ng-if="applicationState.endpoint.apiVersion >= 1.30">Placement preferences</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.30"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
</rd-widget-body>
@ -128,6 +128,7 @@
<!-- access-control-panel -->
<por-access-control-panel
ng-if="service && applicationState.application.authentication"
resource-id="service.Id"
resource-control="service.ResourceControl"
resource-type="'service'">
</por-access-control-panel>
@ -159,11 +160,11 @@
<h3 id="service-specs">Service specification</h3>
<div id="service-resources" class="padding-top" ng-include="'app/components/service/includes/resources.html'"></div>
<div id="service-placement-constraints" class="padding-top" ng-include="'app/components/service/includes/constraints.html'"></div>
<div id="service-placement-preferences" class="padding-top" ng-include="'app/components/service/includes/placementPreferences.html'"></div>
<div id="service-placement-preferences" ng-if="applicationState.endpoint.apiVersion >= 1.30" class="padding-top" ng-include="'app/components/service/includes/placementPreferences.html'"></div>
<div id="service-restart-policy" class="padding-top" ng-include="'app/components/service/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/components/service/includes/updateconfig.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/components/service/includes/servicelabels.html'"></div>
<div id="service-secrets" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-tasks" class="padding-top" ng-include="'app/components/service/includes/tasks.html'"></div>
</div>
</div>

View File

@ -1,6 +1,6 @@
angular.module('service', [])
.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'Secret', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, Secret, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@ -218,8 +218,10 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
config.UpdateConfig = {
Parallelism: service.UpdateParallelism,
Delay: service.UpdateDelay,
FailureAction: service.UpdateFailureAction
FailureAction: service.UpdateFailureAction,
Order: service.UpdateOrder
};
config.TaskTemplate.RestartPolicy = {
Condition: service.RestartCondition,
Delay: service.RestartDelay,
@ -288,7 +290,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
function initView() {
$('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion;
ServiceService.service($stateParams.id)
.then(function success(data) {
var service = data;
@ -304,21 +306,17 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
return $q.all({
tasks: TaskService.serviceTasks(service.Name),
nodes: NodeService.nodes(),
secrets: Secret.query({}).$promise
secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
});
})
.then(function success(data) {
$scope.tasks = data.tasks;
$scope.nodes = data.nodes;
$scope.secrets = data.secrets.map(function (secret) {
return new SecretViewModel(secret);
});
$scope.secrets = data.secrets;
$timeout(function() {
$anchorScroll();
});
})
.catch(function error(err) {
$scope.secrets = [];
@ -329,20 +327,6 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
});
}
function fetchSecrets() {
$('#loadSecretsSpinner').show();
Secret.query({}, function (d) {
$scope.secrets = d.map(function (secret) {
return new SecretViewModel(secret);
});
$('#loadSecretsSpinner').hide();
}, function(e) {
$('#loadSecretsSpinner').hide();
Notifications.error('Failure', e, 'Unable to retrieve secrets');
$scope.secrets = [];
});
}
$scope.updateServiceAttribute = function updateServiceAttribute(service, name) {
if (service[name] !== originalService[name] || !(name in originalService)) {
service.hasChanges = true;

View File

@ -0,0 +1,254 @@
<rd-header>
<rd-header-title title="Authentication settings">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="settings">Settings</a> &gt; Authentication
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-users" title="Authentication"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Authentication method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="registry_quay" ng-model="settings.AuthenticationMethod" ng-value="1">
<label for="registry_quay">
<div class="boxselector_header">
<i class="fa fa-users" aria-hidden="true" style="margin-right: 2px;"></i>
Internal
</div>
<p>Internal authentication mechanism</p>
</label>
</div>
<div>
<input type="radio" id="registry_custom" ng-model="settings.AuthenticationMethod" ng-value="2">
<label for="registry_custom">
<div class="boxselector_header">
<i class="fa fa-users" aria-hidden="true" style="margin-right: 2px;"></i>
LDAP
</div>
<p>LDAP authentication</p>
</label>
</div>
</div>
</div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 1">
<span class="col-sm-12 text-muted small">
When using internal authentication, Portainer will encrypt user passwords and store credentials locally.
</span>
</div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 2">
<span class="col-sm-12 text-muted small">
When using LDAP authentication, Portainer will delegate user authentication to a LDAP server (exception for the <b>admin</b> user that always uses internal authentication).
<p style="margin-top:5px;">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<u>Users still need to be created in Portainer beforehand.</u>
</p>
</span>
</div>
<div ng-if="settings.AuthenticationMethod === 2">
<div class="col-sm-12 form-section-title">
LDAP configuration
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left">
LDAP URL
<portainer-tooltip position="bottom" message="URL or IP address of the LDAP server."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ldap_url" ng-model="LDAPSettings.URL" placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389">
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 col-lg-2 control-label text-left">
Reader DN
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ldap_username" ng-model="LDAPSettings.ReaderDN" placeholder="cn=readonly-account,dc=ldap,dc=domain,dc=tld">
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Password
</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="ldap_password" ng-model="LDAPSettings.Password" placeholder="password">
</div>
</div>
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLS && !LDAPSettings.StartTLS">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon" style="margin-left: 5px;" ng-if="state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon" style="margin-left: 5px;" ng-if="state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!LDAPSettings.URL || !LDAPSettings.ReaderDN || !LDAPSettings.Password" ng-click="LDAPConnectivityCheck()">Test connectivity</button>
<i id="connectivityCheckSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<div class="col-sm-12 form-section-title">
LDAP security
</div>
<!-- starttls -->
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Use StartTLS
<portainer-tooltip position="bottom" message="Enable this option if want to use StartTLS to secure the connection to the server. Ignored if Use TLS is selected."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.StartTLS"><i></i>
</label>
</div>
</div>
<!-- !starttls -->
<!-- tls-checkbox -->
<div class="form-group" ng-if="!LDAPSettings.StartTLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Use TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the LDAP server."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.TLSConfig.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->
<!-- tls-skip-verify -->
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Skip verification of server certificate
<portainer-tooltip position="bottom" message="Skip the verification of the server TLS certificate. Not recommended on unsecured networks."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.TLSConfig.TLSSkipVerify"><i></i>
</label>
</div>
</div>
<!-- !tls-skip-verify -->
<!-- tls-certs -->
<div ng-if="LDAPSettings.TLSConfig.TLS || LDAPSettings.StartTLS">
<!-- ca-input -->
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLSSkipVerify">
<label class="col-sm-2 control-label text-left">TLS CA certificate</label>
<div class="col-sm-10">
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCACert">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.TLSCACert.name }}
<i class="fa fa-check green-icon" ng-if="formValues.TLSCACert && formValues.TLSCACert === LDAPSettings.TLSConfig.TLSCACert" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCACert" aria-hidden="true"></i>
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
</span>
</div>
</div>
<!-- !ca-input -->
</div>
<!-- !tls-certs -->
<div class="form-group" ng-if="LDAPSettings.TLSConfig.TLS || LDAPSettings.StartTLS">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon" style="margin-left: 5px;" ng-if="state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon" style="margin-left: 5px;" ng-if="state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-primary btn-sm" ng-click="LDAPConnectivityCheck()" ng-disabled="!LDAPSettings.URL || !LDAPSettings.ReaderDN || !LDAPSettings.Password || (!formValues.TLSCACert && !LDAPSettings.TLSConfig.TLSSkipVerify)">Test connectivity</button>
<i id="connectivityCheckSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<div class="col-sm-12 form-section-title">
User search configurations
</div>
<!-- search-settings -->
<div ng-repeat="config in LDAPSettings.SearchSettings | limitTo: (1 - LDAPSettings.SearchSettings)" style="margin-top: 5px;">
<div class="form-group" ng-if="$index > 0">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_basedn_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for users."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_basedn_{{$index}}" ng-model="config.BaseDN" placeholder="dc=ldap,dc=domain,dc=tld">
</div>
<label for="ldap_username_att_{{$index}}" class="col-sm-4 col-md-3 col-lg-2 margin-sm-top control-label text-left">
Username attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the username."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-3 col-lg-4 margin-sm-top">
<input type="text" class="form-control" id="ldap_username_att_{{$index}}" ng-model="config.UserNameAttribute" placeholder="uid">
</div>
</div>
<div class="form-group">
<label for="ldap_filter_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select user elements, optional."></portainer-tooltip>
</label>
<div class="col-sm-7 col-md-9">
<input type="text" class="form-control" id="ldap_filter_{{$index}}" ng-model="config.Filter" placeholder="(objectClass=account)">
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeSearchConfiguration($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-group">
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addSearchConfiguration()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add search configuration
</span>
</div>
</div>
<!-- !search-settings -->
</div>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="saveSettings()">Save</button>
<i id="updateSettingsSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<!-- <span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span> -->
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,93 @@
angular.module('settingsAuthentication', [])
.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService',
function ($q, $scope, Notifications, SettingsService, FileUploadService) {
$scope.state = {
successfulConnectivityCheck: false,
failedConnectivityCheck: false,
uploadInProgress: false
};
$scope.formValues = {
TLSCACert: ''
};
$scope.addSearchConfiguration = function() {
$scope.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' });
};
$scope.removeSearchConfiguration = function(index) {
$scope.LDAPSettings.SearchSettings.splice(index, 1);
};
$scope.LDAPConnectivityCheck = function() {
$('#connectivityCheckSpinner').show();
var settings = $scope.settings;
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify;
$scope.state.uploadInProgress = uploadRequired;
$q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
.then(function success(data) {
return SettingsService.checkLDAPConnectivity(settings);
})
.then(function success(data) {
$scope.state.failedConnectivityCheck = false;
$scope.state.successfulConnectivityCheck = true;
Notifications.success('Connection to LDAP successful');
})
.catch(function error(err) {
$scope.state.failedConnectivityCheck = true;
$scope.state.successfulConnectivityCheck = false;
Notifications.error('Failure', err, 'Connection to LDAP failed');
})
.finally(function final() {
$scope.state.uploadInProgress = false;
$('#connectivityCheckSpinner').hide();
});
};
$scope.saveSettings = function() {
$('#updateSettingsSpinner').show();
var settings = $scope.settings;
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify;
$scope.state.uploadInProgress = uploadRequired;
$q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
.then(function success(data) {
return SettingsService.update(settings);
})
.then(function success(data) {
Notifications.success('Authentication settings updated');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update authentication settings');
})
.finally(function final() {
$scope.state.uploadInProgress = false;
$('#updateSettingsSpinner').hide();
});
};
function initView() {
$('#loadingViewSpinner').show();
SettingsService.settings()
.then(function success(data) {
var settings = data;
$scope.settings = settings;
$scope.LDAPSettings = settings.LDAPSettings;
$scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -25,7 +25,7 @@
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li>
<li class="sidebar-list">
@ -40,16 +40,16 @@
<li class="sidebar-list">
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<a ui-sref="events" ui-sref-active="active">Events <span class="menu-icon fa fa-history"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || (applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER')">
<a ui-sref="swarm" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<a ui-sref="docker" ui-sref-active="active">Docker <span class="menu-icon fa fa-th"></span></a>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
@ -69,6 +69,9 @@
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="settings" ui-sref-active="active">Settings <span class="menu-icon fa fa-cogs"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'settings' || $state.current.name === 'settings_authentication') && applicationState.application.authentication && isAdmin">
<a ui-sref="settings_authentication" ui-sref-active="active">Authentication</a>
</div>
</li>
</ul>
<div class="sidebar-footer">

View File

@ -52,7 +52,7 @@
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6">
<div class="col-lg-6" ng-if="applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Processes">
<div class="pull-right">

View File

@ -213,5 +213,8 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop,
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container info');
});
$scope.getTop();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider !== 'VMWARE_VIC') {
$scope.getTop();
}
}]);

View File

@ -65,7 +65,7 @@
<thead>
<tr>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderUsers('Username')">
<a ng-click="orderUsers('Username')">
Name
<span ng-show="sortTypeUsers == 'Username' && !sortReverseUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeUsers == 'Username' && sortReverseUsers" class="glyphicon glyphicon-chevron-up"></span>
@ -125,14 +125,14 @@
<thead>
<tr>
<th>
<a ui-sref="team({id: team.Id})" ng-click="orderGroupMembers('Username')">
<a ng-click="orderGroupMembers('Username')">
Name
<span ng-show="sortTypeGroupMembers == 'Username' && !sortReverseGroupMembers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeGroupMembers == 'Username' && sortReverseGroupMembers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="team({id: team.Id})" ng-click="orderGroupMembers('TeamRole')">
<a ng-click="orderGroupMembers('TeamRole')">
Team Role
<span ng-show="sortTypeGroupMembers == 'TeamRole' && !sortReverseGroupMembers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeGroupMembers == 'TeamRole' && sortReverseGroupMembers" class="glyphicon glyphicon-chevron-up"></span>

View File

@ -95,7 +95,7 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="users" ng-click="order('Name')">
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>

View File

@ -68,7 +68,7 @@
</div>
<!-- !env -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<div class="form-group">
<div class="col-sm-12">

View File

@ -144,27 +144,20 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
return containerMapping;
}
function filterNetworksBasedOnProvider(networks) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider === 'DOCKER_SWARM' || endpointProvider === 'DOCKER_SWARM_MODE') {
if (endpointProvider === 'DOCKER_SWARM') {
networks = NetworkService.filterGlobalNetworks(networks);
} else {
networks = NetworkService.filterSwarmModeAttachableNetworks(networks);
}
$scope.globalNetworkCount = networks.length;
NetworkService.addPredefinedLocalNetworks(networks);
}
return networks;
}
function initTemplates() {
var templatesKey = $stateParams.key;
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
$q.all({
templates: TemplateService.getTemplates(templatesKey),
containers: ContainerService.getContainers(0),
networks: NetworkService.networks(),
volumes: VolumeService.getVolumes()
volumes: VolumeService.getVolumes(),
networks: NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM')
})
.then(function success(data) {
$scope.templates = data.templates;
@ -174,8 +167,10 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
});
$scope.availableCategories = _.sortBy(_.uniq(availableCategories));
$scope.runningContainers = data.containers;
$scope.availableNetworks = filterNetworksBasedOnProvider(data.networks);
$scope.availableVolumes = data.volumes.Volumes;
var networks = data.networks;
$scope.availableNetworks = networks;
$scope.globalNetworkCount = networks.length;
})
.catch(function error(err) {
$scope.templates = [];

View File

@ -32,29 +32,6 @@
</label>
</td>
</tr>
<!-- <tr ng-if="!formValues.Administrator">
<td colspan="2">
<label for="teams" class="control-label text-left">
Teams
</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="teams.length === 0">
You have not yet created any team. Head over the <a ui-sref="teams">teams view</a> to manage user teams.</span>
</span>
<span isteven-multi-select
ng-if="teams.length > 0"
input-model="teams"
output-model="formValues.Teams"
button-label="Name"
item-label="Name"
tick-property="ticked"
helper-elements="filter"
search-property="Name"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;"
on-item-click="onTeamClick(data)"
</span>
</td>
</tr> -->
</tbody>
</table>
</rd-widget-body>
@ -62,7 +39,7 @@
</div>
</div>
<div class="row">
<div class="row" ng-if="AuthenticationMethod === 1">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-lock" title="Change user password"></rd-widget-header>

View File

@ -1,6 +1,6 @@
angular.module('user', [])
.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications',
function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications) {
.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', 'SettingsService',
function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications, SettingsService) {
$scope.state = {
updatePasswordError: ''
@ -72,12 +72,14 @@ function ($q, $scope, $state, $stateParams, UserService, ModalService, Notificat
function initView() {
$('#loadingViewSpinner').show();
$q.all({
user: UserService.user($stateParams.id)
user: UserService.user($stateParams.id),
settings: SettingsService.publicSettings()
})
.then(function success(data) {
var user = data.user;
$scope.user = user;
$scope.formValues.Administrator = user.Role === 1 ? true : false;
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve user information');

View File

@ -1,5 +1,6 @@
<rd-header>
<rd-header-title title="User settings">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>User settings</rd-header-content>
</rd-header>
@ -58,7 +59,11 @@
<!-- !confirm-password-input -->
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!formValues.currentPassword || formValues.newPassword.length < 8 || formValues.newPassword !== formValues.confirmPassword" ng-click="updatePassword()">Update password</button>
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="AuthenticationMethod !== 1 || !formValues.currentPassword || formValues.newPassword.length < 8 || formValues.newPassword !== formValues.confirmPassword" ng-click="updatePassword()">Update password</button>
<span class="text-muted small" style="margin-left: 5px;" ng-if="AuthenticationMethod === 2">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
You cannot change your password when using LDAP authentication.
</span>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
angular.module('userSettings', [])
.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications',
function ($scope, $state, $sanitize, Authentication, UserService, Notifications) {
.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', 'SettingsService',
function ($scope, $state, $sanitize, Authentication, UserService, Notifications, SettingsService) {
$scope.formValues = {
currentPassword: '',
newPassword: '',
@ -26,4 +26,19 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications)
}
});
};
function initView() {
SettingsService.publicSettings()
.then(function success(data) {
$scope.AuthenticationMethod = data.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -17,7 +17,10 @@
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="username" class="col-sm-2 control-label text-left">Username</label>
<label for="username" class="col-sm-3 col-lg-2 control-label text-left">
Username
<portainer-tooltip ng-if="AuthenticationMethod === 2" position="bottom" message="Username must exactly match username defined in external LDAP source."></portainer-tooltip>
</label>
<div class="col-sm-8">
<div class="input-group">
<input type="text" class="form-control" id="username" ng-model="formValues.Username" ng-change="checkUsernameValidity()" placeholder="e.g. jdoe">
@ -27,8 +30,8 @@
</div>
<!-- !name-input -->
<!-- new-password-input -->
<div class="form-group">
<label for="password" class="col-sm-2 control-label text-left">Password</label>
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
@ -38,8 +41,8 @@
</div>
<!-- !new-password-input -->
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label text-left">Confirm password</label>
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="confirm_password" class="col-sm-3 col-lg-2 control-label text-left">Confirm password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
@ -95,7 +98,7 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.validUsername || formValues.Username === '' || formValues.Password === '' || formValues.Password !== formValues.ConfirmPassword" ng-click="addUser()"><i class="fa fa-user-plus" aria-hidden="true"></i> Add user</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && formValues.Password === '') || (AuthenticationMethod === 1 && formValues.Password !== formValues.ConfirmPassword)" ng-click="addUser()"><i class="fa fa-user-plus" aria-hidden="true"></i> Add user</button>
<i id="createUserSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.userCreationError" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.userCreationError }}
@ -140,19 +143,26 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="users" ng-click="order('Username')">
<a ng-click="order('Username')">
Name
<span ng-show="sortType == 'Username' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Username' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="users" ng-click="order('RoleName')">
<a ng-click="order('RoleName')">
Role
<span ng-show="sortType == 'RoleName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'RoleName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('AuthenticationMethod')">
Authentication
<span ng-show="sortType == 'AuthenticationMethod' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'AuthenticationMethod' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="isAdmin"></th>
</tr>
</thead>
@ -166,6 +176,10 @@
<i ng-if="user.isTeamLeader" class="fa fa-user-plus" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.RoleName }}
</td>
<td>
<span ng-if="AuthenticationMethod === 1 || user.Id === 1">Internal</span>
<span ng-if="AuthenticationMethod === 2 && user.Id !== 1">LDAP</span>
</td>
<td ng-if="isAdmin">
<a ui-sref="user({id: user.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
</td>

View File

@ -1,6 +1,6 @@
angular.module('users', [])
.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication',
function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) {
.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', 'SettingsService',
function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication, SettingsService) {
$scope.state = {
userCreationError: '',
selectedItemCount: 0,
@ -140,13 +140,15 @@ function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershi
$q.all({
users: UserService.users(true),
teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID),
memberships: TeamMembershipService.memberships()
memberships: TeamMembershipService.memberships(),
settings: SettingsService.publicSettings()
})
.then(function success(data) {
var users = data.users;
assignTeamLeaders(users, data.memberships);
$scope.users = users;
$scope.teams = data.teams;
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve users and teams');

View File

@ -50,6 +50,7 @@
<!-- access-control-panel -->
<por-access-control-panel
ng-if="volume && applicationState.application.authentication"
resource-id="volume.Id"
resource-control="volume.ResourceControl"
resource-type="'volume'">
</por-access-control-panel>

View File

@ -38,7 +38,7 @@
Attached
</label>
<label class="btn btn-primary" ng-model="state.danglingVolumesOnly" uib-btn-radio="true">
Dangling
Unused
</label>
</span>
</rd-widget-taskbar>
@ -85,7 +85,7 @@
<td><input type="checkbox" ng-model="volume.Checked" ng-change="selectItem(volume)"/></td>
<td>
<a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Dangling</span></td>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td>
</td>
<td>{{ volume.Driver }}</td>
<td>{{ volume.Mountpoint | truncatelr }}</td>

View File

@ -1,6 +1,6 @@
angular.module('portainer')
.controller('porAccessControlFormController', ['$q', 'UserService', 'Notifications', 'Authentication', 'ResourceControlService',
function ($q, UserService, Notifications, Authentication, ResourceControlService) {
.controller('porAccessControlFormController', ['$q', 'UserService', 'TeamService', 'Notifications', 'Authentication', 'ResourceControlService',
function ($q, UserService, TeamService, Notifications, Authentication, ResourceControlService) {
var ctrl = this;
ctrl.availableTeams = [];
@ -42,7 +42,7 @@ function ($q, UserService, Notifications, Authentication, ResourceControlService
}
$q.all({
availableTeams: UserService.userTeams(userDetails.ID),
availableTeams: TeamService.teams(),
availableUsers: isAdmin ? UserService.users(false) : []
})
.then(function success(data) {

View File

@ -2,6 +2,8 @@ angular.module('portainer').component('porAccessControlPanel', {
templateUrl: 'app/directives/accessControlPanel/porAccessControlPanel.html',
controller: 'porAccessControlPanelController',
bindings: {
// The component will use this identifier when updating the resource control object.
resourceId: '<',
// The component will display information about this resource control object.
resourceControl: '=',
// This component is usually displayed inside a resource-details view.

View File

@ -1,6 +1,6 @@
angular.module('portainer')
.controller('porAccessControlPanelController', ['$q', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'FormValidator',
function ($q, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) {
.controller('porAccessControlPanelController', ['$q', '$state', 'UserService', 'TeamService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'FormValidator',
function ($q, $state, UserService, TeamService, ResourceControlService, Notifications, Authentication, ModalService, FormValidator) {
var ctrl = this;
@ -73,7 +73,7 @@ function ($q, $state, UserService, ResourceControlService, Notifications, Authen
function updateOwnership() {
$('#loadingViewSpinner').show();
var resourceId = ctrl.resourceControl.ResourceId;
var resourceId = ctrl.resourceId;
var ownershipParameters = processOwnershipFormValues();
ResourceControlService.applyResourceControlChange(ctrl.resourceType, resourceId,
@ -121,7 +121,7 @@ function ($q, $state, UserService, ResourceControlService, Notifications, Authen
return $q.all({
availableUsers: isAdmin ? UserService.users(false) : [],
availableTeams: isAdmin || data.isPartOfRestrictedUsers ? UserService.userTeams(userId) : []
availableTeams: isAdmin || data.isPartOfRestrictedUsers ? TeamService.teams() : []
});
})
.then(function success(data) {

View File

@ -12,7 +12,11 @@ function ($q, RegistryService, DockerHubService, Notifications) {
var dockerhub = data.dockerhub;
var registries = data.registries;
ctrl.availableRegistries = [dockerhub].concat(registries);
ctrl.registry = dockerhub;
if (!ctrl.registry.Id) {
ctrl.registry = dockerhub;
} else {
ctrl.registry = _.find(ctrl.availableRegistries, { 'Id': ctrl.registry.Id });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve registries');

View File

@ -1,3 +1,9 @@
function includeString(text, values) {
return values.some(function(val){
return text.indexOf(val) !== -1;
});
}
angular.module('portainer.filters', [])
.filter('truncate', function () {
'use strict';
@ -35,15 +41,13 @@ angular.module('portainer.filters', [])
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('new') !== -1 || status.indexOf('allocated') !== -1 ||
status.indexOf('assigned') !== -1 || status.indexOf('accepted') !== -1) {
if (includeString(status, ['new', 'allocated', 'assigned', 'accepted'])) {
return 'info';
} else if (status.indexOf('pending') !== -1) {
} else if (includeString(status, ['pending'])) {
return 'warning';
} else if (status.indexOf('shutdown') !== -1 || status.indexOf('failed') !== -1 ||
status.indexOf('rejected') !== -1) {
} else if (includeString(status, ['shutdown', 'failed', 'rejected'])) {
return 'danger';
} else if (status.indexOf('complete') !== -1) {
} else if (includeString(status, ['complete'])) {
return 'primary';
}
return 'success';
@ -53,11 +57,11 @@ angular.module('portainer.filters', [])
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1 || status.indexOf('starting') !== -1) {
if (includeString(status, ['paused', 'starting'])) {
return 'warning';
} else if (status.indexOf('created') !== -1) {
} else if (includeString(status, ['created'])) {
return 'info';
} else if (status.indexOf('stopped') !== -1 || status.indexOf('unhealthy') !== -1) {
} else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) {
return 'danger';
}
return 'success';
@ -67,17 +71,19 @@ angular.module('portainer.filters', [])
'use strict';
return function (text) {
var status = _.toLower(text);
if (status.indexOf('paused') !== -1) {
if (includeString(status, ['paused'])) {
return 'paused';
} else if (status.indexOf('created') !== -1) {
} else if (includeString(status, ['dead'])) {
return 'dead';
} else if (includeString(status, ['created'])) {
return 'created';
} else if (status.indexOf('exited') !== -1) {
} else if (includeString(status, ['exited'])) {
return 'stopped';
} else if (status.indexOf('(healthy)') !== -1) {
} else if (includeString(status, ['(healthy)'])) {
return 'healthy';
} else if (status.indexOf('(unhealthy)') !== -1) {
} else if (includeString(status, ['(unhealthy)'])) {
return 'unhealthy';
} else if (status.indexOf('(health: starting)') !== -1) {
} else if (includeString(status, ['(health: starting)'])) {
return 'starting';
}
return 'running';
@ -113,6 +119,9 @@ angular.module('portainer.filters', [])
if (state === undefined) {
return '';
}
if (state.Dead) {
return 'Dead';
}
if (state.Ghost && state.Running) {
return 'Ghost';
}

View File

@ -7,5 +7,56 @@ angular.module('portainer.helpers')
return splitargs(command);
};
helper.commandArrayToString = function(array) {
return array.map(function(elem) {
return '\'' + elem + '\'';
}).join(' ');
};
helper.configFromContainer = function(container) {
var config = container.Config;
// HostConfig
config.HostConfig = container.HostConfig;
// Name
config.name = container.Name.replace(/^\//g, '');
// Network
var mode = config.HostConfig.NetworkMode;
config.NetworkingConfig = {
'EndpointsConfig': {}
};
config.NetworkingConfig.EndpointsConfig = container.NetworkSettings.Networks;
if (mode.indexOf('container:') !== -1) {
delete config.Hostname;
delete config.ExposedPorts;
}
// Set volumes
var binds = [];
var volumes = {};
for (var v in container.Mounts) {
if ({}.hasOwnProperty.call(container.Mounts, v)) {
var mount = container.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
var name = mount.Name || mount.Source;
var containerPath = mount.Destination;
if (name && containerPath) {
var bind = name + ':' + containerPath;
volumes[containerPath] = {};
if (mount.RW === false) {
bind += ':ro';
}
binds.push(bind);
}
}
}
config.HostConfig.Binds = binds;
config.Volumes = volumes;
return config;
};
return helper;
}]);

View File

@ -8,10 +8,15 @@ angular.module('portainer.helpers')
var slashCount = _.countBy(repository)['/'];
var registry = null;
var image = repository;
if (slashCount > 1) {
// assume something/some/thing[/...]
if (slashCount >= 1) {
// assume something/something[/...]
registry = repository.substr(0, repository.indexOf('/'));
image = repository.substr(repository.indexOf('/') + 1);
// assume valid DNS name or IP (contains at least one '.')
if (_.countBy(registry)['.'] > 0) {
image = repository.substr(repository.indexOf('/') + 1);
} else {
registry = null;
}
}
return {

View File

@ -16,7 +16,11 @@ angular.module('portainer.helpers')
}
} else {
if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) {
mode.provider = 'DOCKER_STANDALONE';
if (info.ID === 'vSphere Integrated Containers') {
mode.provider = 'VMWARE_VIC';
} else {
mode.provider = 'DOCKER_STANDALONE';
}
} else {
mode.provider = 'DOCKER_SWARM_MODE';
if (info.Swarm.ControlAvailable) {

View File

@ -0,0 +1,12 @@
function LDAPSettingsViewModel(data) {
this.ReaderDN = data.ReaderDN;
this.Password = data.Password;
this.URL = data.URL;
this.SearchSettings = data.SearchSettings;
}
function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) {
this.BaseDN = BaseDN;
this.UsernameAttribute = UsernameAttribute;
this.Filter = Filter;
}

View File

@ -3,4 +3,6 @@ function SettingsViewModel(data) {
this.LogoURL = data.LogoURL;
this.BlackListedLabels = data.BlackListedLabels;
this.DisplayExternalContributors = data.DisplayExternalContributors;
this.AuthenticationMethod = data.AuthenticationMethod;
this.LDAPSettings = data.LDAPSettings;
}

View File

@ -7,5 +7,6 @@ function UserViewModel(data) {
} else {
this.RoleName = 'user';
}
this.AuthenticationMethod = data.AuthenticationMethod;
this.Checked = false;
}

View File

@ -11,14 +11,18 @@ function ContainerViewModel(data) {
this.Command = data.Command;
this.Checked = false;
this.Labels = data.Labels;
this.Ports = [];
this.Mounts = data.Mounts;
for (var i = 0; i < data.Ports.length; ++i) {
var p = data.Ports[i];
if (p.PublicPort) {
this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort });
this.Ports = [];
if (data.Ports) {
for (var i = 0; i < data.Ports.length; ++i) {
var p = data.Ports[i];
if (p.PublicPort) {
this.Ports.push({ host: p.IP, private: p.PrivatePort, public: p.PublicPort });
}
}
}
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);

View File

@ -1,6 +1,8 @@
function ContainerDetailsViewModel(data) {
this.Model = data;
this.Id = data.Id;
this.State = data.State;
this.Created = data.Created;
this.Name = data.Name;
this.NetworkSettings = data.NetworkSettings;
this.Args = data.Args;

View File

@ -0,0 +1,9 @@
// This model is based on https://github.com/moby/moby/blob/0ac25dfc751fa4304ab45afd5cd8705c2235d101/api/types/plugin.go#L8-L31
// instead of the official documentation.
// See: https://github.com/moby/moby/issues/34241
function PluginViewModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Enabled = data.Enabled;
this.Config = data.Config;
}

View File

@ -79,10 +79,12 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.UpdateParallelism = (typeof data.Spec.UpdateConfig.Parallelism !== undefined) ? data.Spec.UpdateConfig.Parallelism || 0 : 1;
this.UpdateDelay = data.Spec.UpdateConfig.Delay || 0;
this.UpdateFailureAction = data.Spec.UpdateConfig.FailureAction || 'pause';
this.UpdateOrder = data.Spec.UpdateConfig.Order || 'stop-first';
} else {
this.UpdateParallelism = 1;
this.UpdateDelay = 0;
this.UpdateFailureAction = 'pause';
this.UpdateOrder = 'stop-first';
}
this.RollbackConfig = data.Spec.RollbackConfig;

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Auth', ['$resource', 'AUTH_ENDPOINT', function AuthFactory($resource, AUTH_ENDPOINT) {
.factory('Auth', ['$resource', 'API_ENDPOINT_AUTH', function AuthFactory($resource, API_ENDPOINT_AUTH) {
'use strict';
return $resource(AUTH_ENDPOINT, {}, {
return $resource(API_ENDPOINT_AUTH, {}, {
login: {
method: 'POST'
}

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('DockerHub', ['$resource', 'DOCKERHUB_ENDPOINT', function DockerHubFactory($resource, DOCKERHUB_ENDPOINT) {
.factory('DockerHub', ['$resource', 'API_ENDPOINT_DOCKERHUB', function DockerHubFactory($resource, API_ENDPOINT_DOCKERHUB) {
'use strict';
return $resource(DOCKERHUB_ENDPOINT, {}, {
return $resource(API_ENDPOINT_DOCKERHUB, {}, {
get: { method: 'GET' },
update: { method: 'PUT' }
});

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Endpoints', ['$resource', 'ENDPOINTS_ENDPOINT', function EndpointsFactory($resource, ENDPOINTS_ENDPOINT) {
.factory('Endpoints', ['$resource', 'API_ENDPOINT_ENDPOINTS', function EndpointsFactory($resource, API_ENDPOINT_ENDPOINTS) {
'use strict';
return $resource(ENDPOINTS_ENDPOINT + '/:id/:action', {}, {
return $resource(API_ENDPOINT_ENDPOINTS + '/:id/:action', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Registries', ['$resource', 'REGISTRIES_ENDPOINT', function RegistriesFactory($resource, REGISTRIES_ENDPOINT) {
.factory('Registries', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistriesFactory($resource, API_ENDPOINT_REGISTRIES) {
'use strict';
return $resource(REGISTRIES_ENDPOINT + '/:id/:action', {}, {
return $resource(API_ENDPOINT_REGISTRIES + '/:id/:action', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('ResourceControl', ['$resource', 'RESOURCE_CONTROL_ENDPOINT', function ResourceControlFactory($resource, RESOURCE_CONTROL_ENDPOINT) {
.factory('ResourceControl', ['$resource', 'API_ENDPOINT_RESOURCE_CONTROLS', function ResourceControlFactory($resource, API_ENDPOINT_RESOURCE_CONTROLS) {
'use strict';
return $resource(RESOURCE_CONTROL_ENDPOINT + '/:id', {}, {
return $resource(API_ENDPOINT_RESOURCE_CONTROLS + '/:id', {}, {
create: { method: 'POST' },
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },

View File

@ -1,8 +1,10 @@
angular.module('portainer.rest')
.factory('Settings', ['$resource', 'SETTINGS_ENDPOINT', function SettingsFactory($resource, SETTINGS_ENDPOINT) {
.factory('Settings', ['$resource', 'API_ENDPOINT_SETTINGS', function SettingsFactory($resource, API_ENDPOINT_SETTINGS) {
'use strict';
return $resource(SETTINGS_ENDPOINT, {}, {
return $resource(API_ENDPOINT_SETTINGS + '/:subResource/:action', {}, {
get: { method: 'GET' },
update: { method: 'PUT' }
update: { method: 'PUT' },
publicSettings: { method: 'GET', params: { subResource: 'public' } },
checkLDAPConnectivity: { method: 'PUT', params: { subResource: 'authentication', action: 'checkLDAP' } }
});
}]);

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Status', ['$resource', 'STATUS_ENDPOINT', function StatusFactory($resource, STATUS_ENDPOINT) {
.factory('Status', ['$resource', 'API_ENDPOINT_STATUS', function StatusFactory($resource, API_ENDPOINT_STATUS) {
'use strict';
return $resource(STATUS_ENDPOINT, {}, {
return $resource(API_ENDPOINT_STATUS, {}, {
get: { method: 'GET' }
});
}]);

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Teams', ['$resource', 'TEAMS_ENDPOINT', function TeamsFactory($resource, TEAMS_ENDPOINT) {
.factory('Teams', ['$resource', 'API_ENDPOINT_TEAMS', function TeamsFactory($resource, API_ENDPOINT_TEAMS) {
'use strict';
return $resource(TEAMS_ENDPOINT + '/:id/:entity/:entityId', {}, {
return $resource(API_ENDPOINT_TEAMS + '/:id/:entity/:entityId', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('TeamMemberships', ['$resource', 'TEAM_MEMBERSHIPS_ENDPOINT', function TeamMembershipsFactory($resource, TEAM_MEMBERSHIPS_ENDPOINT) {
.factory('TeamMemberships', ['$resource', 'API_ENDPOINT_TEAM_MEMBERSHIPS', function TeamMembershipsFactory($resource, API_ENDPOINT_TEAM_MEMBERSHIPS) {
'use strict';
return $resource(TEAM_MEMBERSHIPS_ENDPOINT + '/:id/:action', {}, {
return $resource(API_ENDPOINT_TEAM_MEMBERSHIPS + '/:id/:action', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
update: { method: 'PUT', params: { id: '@id' } },

View File

@ -1,6 +1,6 @@
angular.module('portainer.rest')
.factory('Template', ['$resource', 'TEMPLATES_ENDPOINT', function TemplateFactory($resource, TEMPLATES_ENDPOINT) {
return $resource(TEMPLATES_ENDPOINT, {}, {
.factory('Template', ['$resource', 'API_ENDPOINT_TEMPLATES', function TemplateFactory($resource, API_ENDPOINT_TEMPLATES) {
return $resource(API_ENDPOINT_TEMPLATES, {}, {
get: {method: 'GET', isArray: true}
});
}]);

View File

@ -1,14 +1,13 @@
angular.module('portainer.rest')
.factory('Users', ['$resource', 'USERS_ENDPOINT', function UsersFactory($resource, USERS_ENDPOINT) {
.factory('Users', ['$resource', 'API_ENDPOINT_USERS', function UsersFactory($resource, API_ENDPOINT_USERS) {
'use strict';
return $resource(USERS_ENDPOINT + '/:id/:entity/:entityId', {}, {
return $resource(API_ENDPOINT_USERS + '/:id/:entity/:entityId', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },
remove: { method: 'DELETE', params: { id: '@id'} },
queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } },
queryTeams: { method: 'GET', isArray: true, params: { id: '@id', entity: 'teams' } },
// RPCs should be moved to a specific endpoint
checkPassword: { method: 'POST', params: { id: '@id', entity: 'passwd' } },
checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true },

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Container', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerFactory($resource, DOCKER_ENDPOINT, EndpointProvider) {
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/containers/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', {
name: '@name',
endpointId: EndpointProvider.endpointID
},

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('ContainerCommit', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerCommitFactory($resource, DOCKER_ENDPOINT, EndpointProvider) {
.factory('ContainerCommit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerCommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/commit', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', {
endpointId: EndpointProvider.endpointID
},
{

View File

@ -1,11 +1,11 @@
angular.module('portainer.rest')
.factory('ContainerLogs', ['$http', 'DOCKER_ENDPOINT', 'EndpointProvider', function ContainerLogsFactory($http, DOCKER_ENDPOINT, EndpointProvider) {
.factory('ContainerLogs', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerLogsFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return {
get: function (id, params, callback) {
$http({
method: 'GET',
url: DOCKER_ENDPOINT + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/logs',
url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/logs',
params: {
'stdout': params.stdout || 0,
'stderr': params.stderr || 0,
@ -13,7 +13,7 @@ angular.module('portainer.rest')
'tail': params.tail || 'all'
}
}).success(callback).error(function (data, status, headers, config) {
console.log(error, data);
console.log(data);
});
}
};

View File

@ -1,15 +1,17 @@
angular.module('portainer.rest')
.factory('ContainerTop', ['$http', 'DOCKER_ENDPOINT', 'EndpointProvider', function ($http, DOCKER_ENDPOINT, EndpointProvider) {
.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: DOCKER_ENDPOINT + '/' + EndpointProvider.endpointID() + '/containers/' + id + '/top',
url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/top',
params: {
ps_args: params.ps_args
}
}).success(callback);
}).success(callback).error(function (data, status, headers, config) {
console.log(data);
});
}
};
}]);

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Exec', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function ExecFactory($resource, DOCKER_ENDPOINT, EndpointProvider) {
.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/exec/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{

View File

@ -1,8 +1,8 @@
angular.module('portainer.rest')
.factory('Image', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, DOCKER_ENDPOINT, EndpointProvider, HttpRequestHelper) {
.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/images/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Network', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function NetworkFactory($resource, DOCKER_ENDPOINT, EndpointProvider) {
.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/networks/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', {
id: '@id',
endpointId: EndpointProvider.endpointID
},

View File

@ -1,7 +1,7 @@
angular.module('portainer.rest')
.factory('Node', ['$resource', 'DOCKER_ENDPOINT', 'EndpointProvider', function NodeFactory($resource, DOCKER_ENDPOINT, EndpointProvider) {
.factory('Node', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NodeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(DOCKER_ENDPOINT + '/:endpointId/nodes/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/nodes/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{

View File

@ -0,0 +1,9 @@
angular.module('portainer.rest')
.factory('Plugin', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function PluginFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/plugins/:id/:action', {
endpointId: EndpointProvider.endpointID
}, {
query: { method: 'GET', isArray: true }
});
}]);

Some files were not shown because too many files have changed in this diff Show More