diff --git a/api/cli/cli.go b/api/cli/cli.go index c4cde81ec..a343195f4 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer" "os" + "path/filepath" "strings" "gopkg.in/alecthomas/kingpin.v2" @@ -54,6 +55,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { } kingpin.Parse() + + if !filepath.IsAbs(*flags.Assets) { + ex, err := os.Executable() + if err != nil { + panic(err) + } + *flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets) + } + return flags, nil } diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 80ba58c51..2d350c1c7 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -5,7 +5,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "/data" - defaultAssetsDirectory = "." + defaultAssetsDirectory = "./" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 5e80489e4..a94657258 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -3,7 +3,7 @@ package cli const ( defaultBindAddress = ":9000" defaultDataDirectory = "C:\\data" - defaultAssetsDirectory = "." + defaultAssetsDirectory = "./" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 920f0b9fd..60bb071e1 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -127,7 +127,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL if err == portainer.ErrSettingsNotFound { settings := &portainer.Settings{ LogoURL: *flags.Logo, - DisplayExternalContributors: true, + DisplayExternalContributors: false, AuthenticationMethod: portainer.AuthenticationInternal, LDAPSettings: portainer.LDAPSettings{ TLSConfig: portainer.TLSConfiguration{}, diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index 3f8b60b79..7e418d70f 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -2,6 +2,7 @@ package exec import ( "bytes" + "os" "os/exec" "path" "runtime" @@ -21,26 +22,68 @@ func NewStackManager(binaryPath string) *StackManager { } } -// Deploy will execute the Docker stack deploy command +// Login executes the docker login command against a list of registries (including DockerHub). +func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error { + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + for _, registry := range registries { + if registry.Authentication { + registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) + err := runCommandAndCaptureStdErr(command, registryArgs, nil) + if err != nil { + return err + } + } + } + + if dockerhub.Authentication { + dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password) + err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil) + if err != nil { + return err + } + } + + return nil +} + +// Logout executes the docker logout command. +func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { + command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) + args = append(args, "logout") + return runCommandAndCaptureStdErr(command, args, nil) +} + +// Deploy executes the docker stack deploy command. func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error { stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) - return runCommandAndCaptureStdErr(command, args) + + env := make([]string, 0) + for _, envvar := range stack.Env { + env = append(env, envvar.Name+"="+envvar.Value) + } + + return runCommandAndCaptureStdErr(command, args, env) } -// Remove will execute the Docker stack rm command +// Remove executes the docker stack rm command. func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint) args = append(args, "stack", "rm", stack.Name) - return runCommandAndCaptureStdErr(command, args) + return runCommandAndCaptureStdErr(command, args, nil) } -func runCommandAndCaptureStdErr(command string, args []string) error { +func runCommandAndCaptureStdErr(command string, args []string, env []string) error { var stderr bytes.Buffer cmd := exec.Command(command, args...) cmd.Stderr = &stderr + if env != nil { + cmd.Env = os.Environ() + cmd.Env = append(cmd.Env, env...) + } + err := cmd.Run() if err != nil { return portainer.Error(stderr.String()) diff --git a/api/http/handler/file.go b/api/http/handler/file.go index 2191169ac..efc7e2b77 100644 --- a/api/http/handler/file.go +++ b/api/http/handler/file.go @@ -3,35 +3,22 @@ package handler import ( "os" - "github.com/portainer/portainer" - httperror "github.com/portainer/portainer/http/error" - "log" "net/http" - "path" "strings" ) // FileHandler represents an HTTP API handler for managing static files. type FileHandler struct { http.Handler - Logger *log.Logger - allowedDirectories map[string]bool + Logger *log.Logger } // NewFileHandler returns a new instance of FileHandler. -func NewFileHandler(assetPath string) *FileHandler { +func NewFileHandler(assetPublicPath string) *FileHandler { h := &FileHandler{ - Handler: http.FileServer(http.Dir(assetPath)), + Handler: http.FileServer(http.Dir(assetPublicPath)), Logger: log.New(os.Stderr, "", log.LstdFlags), - allowedDirectories: map[string]bool{ - "/": true, - "/css": true, - "/js": true, - "/images": true, - "/fonts": true, - "/ico": true, - }, } return h } @@ -46,17 +33,10 @@ func isHTML(acceptContent []string) bool { } func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - requestDirectory := path.Dir(r.URL.Path) - if !handler.allowedDirectories[requestDirectory] { - httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger) - return - } - if !isHTML(r.Header["Accept"]) { w.Header().Set("Cache-Control", "max-age=31536000") } else { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } - handler.Handler.ServeHTTP(w, r) } diff --git a/api/http/handler/resource_control.go b/api/http/handler/resource_control.go index 779431516..fa939bb12 100644 --- a/api/http/handler/resource_control.go +++ b/api/http/handler/resource_control.go @@ -84,6 +84,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht resourceControlType = portainer.SecretResourceControl case "stack": resourceControlType = portainer.StackResourceControl + case "config": + resourceControlType = portainer.ConfigResourceControl default: httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) return diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go index e3e13930c..1f6b23d6e 100644 --- a/api/http/handler/stack.go +++ b/api/http/handler/stack.go @@ -5,6 +5,7 @@ import ( "path" "strconv" "strings" + "sync" "github.com/asaskevich/govalidator" "github.com/portainer/portainer" @@ -22,6 +23,8 @@ import ( // StackHandler represents an HTTP API handler for managing Stack. type StackHandler struct { + stackCreationMutex *sync.Mutex + stackDeletionMutex *sync.Mutex *mux.Router Logger *log.Logger FileService portainer.FileService @@ -29,17 +32,21 @@ type StackHandler struct { StackService portainer.StackService EndpointService portainer.EndpointService ResourceControlService portainer.ResourceControlService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService StackManager portainer.StackManager } // NewStackHandler returns a new instance of StackHandler. func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { h := &StackHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + stackCreationMutex: &sync.Mutex{}, + stackDeletionMutex: &sync.Mutex{}, + Logger: log.New(os.Stderr, "", log.LstdFlags), } h.Handle("/{endpointId}/stacks", - bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) + bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost) h.Handle("/{endpointId}/stacks", bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet) h.Handle("/{endpointId}/stacks/{id}", @@ -55,11 +62,12 @@ func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { type ( postStacksRequest struct { - Name string `valid:"required"` - SwarmID string `valid:"required"` - StackFileContent string `valid:""` - GitRepository string `valid:""` - PathInRepository string `valid:""` + Name string `valid:"required"` + SwarmID string `valid:"required"` + StackFileContent string `valid:""` + GitRepository string `valid:""` + PathInRepository string `valid:""` + Env []portainer.Pair `valid:""` } postStacksResponse struct { ID string `json:"Id"` @@ -68,7 +76,8 @@ type ( StackFileContent string `json:"StackFileContent"` } putStackRequest struct { - StackFileContent string `valid:"required"` + StackFileContent string `valid:"required"` + Env []portainer.Pair `valid:""` } ) @@ -158,6 +167,7 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, Name: stackName, SwarmID: swarmID, EntryPoint: file.ComposeFileDefaultName, + Env: req.Env, } projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent) @@ -173,7 +183,31 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -251,6 +285,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri Name: stackName, SwarmID: swarmID, EntryPoint: req.PathInRepository, + Env: req.Env, } projectPath := handler.FileService.GetStackProjectPath(string(stack.ID)) @@ -275,7 +310,31 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -314,6 +373,13 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r return } + envParam := r.FormValue("Env") + var env []portainer.Pair + if err = json.Unmarshal([]byte(envParam), &env); err != nil { + httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) + return + } + stackFile, _, err := r.FormFile("file") if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) @@ -339,6 +405,7 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r Name: stackName, SwarmID: swarmID, EntryPoint: file.ComposeFileDefaultName, + Env: env, } projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile) @@ -354,7 +421,31 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r return } - err = handler.StackManager.Deploy(stack, endpoint) + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -508,6 +599,7 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger) return } + stack.Env = req.Env _, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent) if err != nil { @@ -515,7 +607,37 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque return } - err = handler.StackManager.Deploy(stack, endpoint) + err = handler.StackService.UpdateStack(stack.ID, stack) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + dockerhub, err := handler.DockerHubService.DockerHub() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + registries, err := handler.RegistryService.Registries() + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + filteredRegistries, err := security.FilterRegistries(registries, securityContext) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -589,11 +711,13 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re return } + handler.stackDeletionMutex.Lock() err = handler.StackManager.Remove(stack, endpoint) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } + handler.stackDeletionMutex.Unlock() err = handler.StackService.DeleteStack(portainer.StackID(stackID)) if err != nil { @@ -607,3 +731,28 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re return } } + +func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error { + handler.stackCreationMutex.Lock() + + err := handler.StackManager.Login(dockerhub, registries, endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + err = handler.StackManager.Deploy(stack, endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + err = handler.StackManager.Logout(endpoint) + if err != nil { + handler.stackCreationMutex.Unlock() + return err + } + + handler.stackCreationMutex.Unlock() + return nil +} diff --git a/api/http/handler/templates.go b/api/http/handler/templates.go index 25e2e288b..83527870b 100644 --- a/api/http/handler/templates.go +++ b/api/http/handler/templates.go @@ -43,16 +43,17 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht } var templatesURL string - if key == "containers" { + switch key { + case "containers": settings, err := handler.SettingsService.Settings() if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } templatesURL = settings.TemplatesURL - } else if key == "linuxserver.io" { + case "linuxserver.io": templatesURL = containerTemplatesURLLinuxServerIo - } else { + default: httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go new file mode 100644 index 000000000..16904c6c2 --- /dev/null +++ b/api/http/proxy/configs.go @@ -0,0 +1,107 @@ +package proxy + +import ( + "net/http" + + "github.com/portainer/portainer" +) + +const ( + // ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier + ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found") + configIdentifier = "ID" +) + +// configListOperation extracts the response as a JSON object, loop through the configs array +// decorate and/or filter the configs based on resource controls before rewriting the response +func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + var err error + + // ConfigList response is a JSON array + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigList + responseArray, err := getResponseAsJSONArray(response) + if err != nil { + return err + } + + if executor.operationContext.isAdmin { + responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls) + } else { + responseArray, err = filterConfigList(responseArray, executor.operationContext) + } + if err != nil { + return err + } + + return rewriteResponse(response, responseArray, http.StatusOK) +} + +// configInspectOperation extracts the response as a JSON object, verify that the user +// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID) +// and either rewrite an access denied response or a decorated config. +func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { + // ConfigInspect response is a JSON object + // https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect + responseObject, err := getResponseAsJSONOBject(response) + if err != nil { + return err + } + + if responseObject[configIdentifier] == nil { + return ErrDockerConfigIdentifierNotFound + } + + configID := responseObject[configIdentifier].(string) + responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext) + if !access { + return rewriteAccessDeniedResponse(response) + } + + return rewriteResponse(response, responseObject, http.StatusOK) +} + +// decorateConfigList loops through all configs and decorates any config with an existing resource control. +// Resource controls checks are based on: resource identifier. +// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList +func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) { + decoratedConfigData := make([]interface{}, 0) + + for _, config := range configData { + + configObject := config.(map[string]interface{}) + if configObject[configIdentifier] == nil { + return nil, ErrDockerConfigIdentifierNotFound + } + + configID := configObject[configIdentifier].(string) + configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls) + + decoratedConfigData = append(decoratedConfigData, configObject) + } + + return decoratedConfigData, nil +} + +// filterConfigList loops through all configs and filters public configs (no associated resource control) +// as well as authorized configs (access granted to the user based on existing resource control). +// Authorized configs are decorated during the process. +// Resource controls checks are based on: resource identifier. +// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList +func filterConfigList(configData []interface{}, context *restrictedOperationContext) ([]interface{}, error) { + filteredConfigData := make([]interface{}, 0) + + for _, config := range configData { + configObject := config.(map[string]interface{}) + if configObject[configIdentifier] == nil { + return nil, ErrDockerConfigIdentifierNotFound + } + + configID := configObject[configIdentifier].(string) + configObject, access := applyResourceAccessControl(configObject, configID, context) + if access { + filteredConfigData = append(filteredConfigData, configObject) + } + } + + return filteredConfigData, nil +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index d5febb22a..83edcaf37 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -41,6 +41,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon path := request.URL.Path switch { + case strings.HasPrefix(path, "/configs"): + return p.proxyConfigRequest(request) case strings.HasPrefix(path, "/containers"): return p.proxyContainerRequest(request) case strings.HasPrefix(path, "/services"): @@ -62,6 +64,24 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon } } +func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) { + switch requestPath := request.URL.Path; requestPath { + case "/configs/create": + return p.executeDockerRequest(request) + + case "/configs": + return p.rewriteOperation(request, configListOperation) + + default: + // assume /configs/{id} + if request.Method == http.MethodGet { + return p.rewriteOperation(request, configInspectOperation) + } + configID := path.Base(requestPath) + return p.restrictedOperation(request, configID) + } +} + func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) { switch requestPath := request.URL.Path; requestPath { case "/containers/create": diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 7e7f56c7c..9f28f19c0 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -61,7 +61,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po } // FilterRegistries filters registries based on user role and team memberships. -// Non administrator users only have access to authorized endpoints. +// Non administrator users only have access to authorized registries. func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) { filteredRegistries := registries diff --git a/api/http/server.go b/api/http/server.go index e3f31e8d1..d0402bed4 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -7,6 +7,7 @@ import ( "github.com/portainer/portainer/http/security" "net/http" + "path/filepath" ) // Server implements the portainer.Server interface @@ -42,7 +43,7 @@ func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) - var fileHandler = handler.NewFileHandler(server.AssetsPath) + var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) authHandler.UserService = server.UserService authHandler.CryptoService = server.CryptoService @@ -93,6 +94,8 @@ func (server *Server) Start() error { stackHandler.ResourceControlService = server.ResourceControlService stackHandler.StackManager = server.StackManager stackHandler.GitService = server.GitService + stackHandler.RegistryService = server.RegistryService + stackHandler.DockerHubService = server.DockerHubService server.Handler = &handler.Handler{ AuthHandler: authHandler, diff --git a/api/portainer.go b/api/portainer.go index e1bdf9455..26467a87c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -138,6 +138,7 @@ type ( EntryPoint string `json:"EntryPoint"` SwarmID string `json:"SwarmId"` ProjectPath string + Env []Pair `json:"Env"` } // RegistryID represents a registry identifier. @@ -379,6 +380,8 @@ type ( // StackManager represents a service to manage stacks. StackManager interface { + Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error + Logout(endpoint *Endpoint) error Deploy(stack *Stack, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } @@ -386,7 +389,7 @@ type ( const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.15.0" + APIVersion = "1.15.1" // DBVersion is the version number of the Portainer database. DBVersion = 6 // DefaultTemplatesURL represents the default URL for the templates definitions. @@ -446,4 +449,6 @@ const ( SecretResourceControl // StackResourceControl represents a resource control associated to a stack composed of Docker services StackResourceControl + // ConfigResourceControl represents a resource control associated to a Docker config + ConfigResourceControl ) diff --git a/api/swagger.yaml b/api/swagger.yaml index 490e43226..61e2c723a 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -56,7 +56,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.15.0" + version: "1.15.1" title: "Portainer API" contact: email: "info@portainer.io" @@ -1869,7 +1869,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.15.0" + example: "1.15.1" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/app/__module.js b/app/__module.js index 843fe2239..e4468a0d0 100644 --- a/app/__module.js +++ b/app/__module.js @@ -16,12 +16,16 @@ angular.module('portainer', [ 'portainer.services', 'auth', 'dashboard', + 'config', + 'configs', 'container', 'containerConsole', 'containerLogs', 'containerStats', + 'containerInspect', 'serviceLogs', 'containers', + 'createConfig', 'createContainer', 'createNetwork', 'createRegistry', diff --git a/app/components/config/config.html b/app/components/config/config.html new file mode 100644 index 000000000..ec7497c79 --- /dev/null +++ b/app/components/config/config.html @@ -0,0 +1,81 @@ + + + + + + + + + Configs > {{ config.Name }} + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ config.Name }}
ID + {{ config.Id }} + +
Created{{ config.CreatedAt | getisodate }}
Last updated{{ config.UpdatedAt | getisodate }}
Labels + + + + + +
{{ k }}{{ v }}
+
+
+
+
+
+ + + + + + +
+
+ + + +
+
+
+ +
+
+
+
+
+
+
diff --git a/app/components/config/configController.js b/app/components/config/configController.js new file mode 100644 index 000000000..6ae37c21f --- /dev/null +++ b/app/components/config/configController.js @@ -0,0 +1,45 @@ +angular.module('config', []) +.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService', +function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) { + + $scope.removeConfig = function removeConfig(configId) { + $('#loadingViewSpinner').show(); + ConfigService.remove(configId) + .then(function success(data) { + Notifications.success('Config successfully removed'); + $state.go('configs', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove config'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + }; + + function initEditor() { + $document.ready(function() { + var webEditorElement = $document[0].getElementById('config-editor'); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true); + } + }); + } + + function initView() { + $('#loadingViewSpinner').show(); + ConfigService.config($transition$.params().id) + .then(function success(data) { + $scope.config = data; + initEditor(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve config details'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/configs/configs.html b/app/components/configs/configs.html new file mode 100644 index 000000000..3464a8508 --- /dev/null +++ b/app/components/configs/configs.html @@ -0,0 +1,81 @@ + + + + + + + + Configs + + +
+
+ + + + +
+ + Add config +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + Name + + + + + + Created at + + + + + + Ownership + + + +
{{ config.Name }}{{ config.CreatedAt | getisodate }} + + + {{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }} + +
Loading...
No configs available.
+
+ +
+
+
+ +
+
diff --git a/app/components/configs/configsController.js b/app/components/configs/configsController.js new file mode 100644 index 000000000..64f894986 --- /dev/null +++ b/app/components/configs/configsController.js @@ -0,0 +1,76 @@ +angular.module('configs', []) +.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination', +function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) { + $scope.state = {}; + $scope.state.selectedItemCount = 0; + $scope.state.pagination_count = Pagination.getPaginationCount('configs'); + $scope.sortType = 'Name'; + $scope.sortReverse = false; + + $scope.order = function (sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + $scope.selectItems = function (allSelected) { + angular.forEach($scope.state.filteredConfigs, function (config) { + if (config.Checked !== allSelected) { + config.Checked = allSelected; + $scope.selectItem(config); + } + }); + }; + + $scope.selectItem = function (item) { + if (item.Checked) { + $scope.state.selectedItemCount++; + } else { + $scope.state.selectedItemCount--; + } + }; + + $scope.removeAction = function () { + $('#loadingViewSpinner').show(); + var counter = 0; + var complete = function () { + counter = counter - 1; + if (counter === 0) { + $('#loadingViewSpinner').hide(); + } + }; + angular.forEach($scope.configs, function (config) { + if (config.Checked) { + counter = counter + 1; + ConfigService.remove(config.Id) + .then(function success() { + Notifications.success('Config deleted', config.Id); + var index = $scope.configs.indexOf(config); + $scope.configs.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove config'); + }) + .finally(function final() { + complete(); + }); + } + }); + }; + + function initView() { + $('#loadingViewSpinner').show(); + ConfigService.configs() + .then(function success(data) { + $scope.configs = data; + }) + .catch(function error(err) { + $scope.configs = []; + Notifications.error('Failure', err, 'Unable to retrieve configs'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/container/container.html b/app/components/container/container.html index 34590f292..2608d9323 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -83,6 +83,7 @@ Stats Logs Console + Inspect @@ -240,7 +241,7 @@ -
+
@@ -248,14 +249,15 @@ - - + + - - - + + + +
HostContainerHost/volumePath in container
{{ vol|key: ':' }}{{ vol|value: ':' }}
{{ vol.Source }}{{ vol.Name }}{{ vol.Destination }}
diff --git a/app/components/container/containerController.js b/app/components/container/containerController.js index 2ab871f48..e7a18a95f 100644 --- a/app/components/container/containerController.js +++ b/app/components/container/containerController.js @@ -197,10 +197,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, }; $scope.duplicate = function() { - ModalService.confirmExperimentalFeature(function (experimental) { - if(!experimental) { return; } - $state.go('actions.create.container', {from: $transition$.params().id}, {reload: true}); - }); + $state.go('actions.create.container', {from: $transition$.params().id}, {reload: true}); }; $scope.confirmRemove = function () { @@ -264,17 +261,13 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, } $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); - }); + ModalService.confirmContainerRecreation(function (result) { + if(!result) { return; } + var pullImage = false; + if (result[0]) { + pullImage = true; + } + recreateContainer(pullImage); }); }; diff --git a/app/components/containerInspect/containerInspect.html b/app/components/containerInspect/containerInspect.html new file mode 100644 index 000000000..c0bd20ebb --- /dev/null +++ b/app/components/containerInspect/containerInspect.html @@ -0,0 +1,24 @@ + + + + + Containers > {{ containerInfo.Name|trimcontainername }} > Inspect + + + +
+
+ + + + + + + + +
{{ containerInfo|json:4 }}
+ +
+
+
+
diff --git a/app/components/containerInspect/containerInspectController.js b/app/components/containerInspect/containerInspectController.js new file mode 100644 index 000000000..ea4219814 --- /dev/null +++ b/app/components/containerInspect/containerInspectController.js @@ -0,0 +1,24 @@ +angular.module('containerInspect', ['angular-json-tree']) +.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', +function ($scope, $transition$, Notifications, ContainerService) { + + $scope.state = { DisplayTextView: false }; + $scope.containerInfo = {}; + + function initView() { + $('#loadingViewSpinner').show(); + + ContainerService.inspect($transition$.params().id) + .then(function success(d) { + $scope.containerInfo = d; + }) + .catch(function error(e) { + Notifications.error('Failure', e, 'Unable to inspect container'); + }) + .finally(function final() { + $('#loadingViewSpinner').hide(); + }); + } + + initView(); +}]); diff --git a/app/components/createConfig/createConfigController.js b/app/components/createConfig/createConfigController.js new file mode 100644 index 000000000..f02a334e3 --- /dev/null +++ b/app/components/createConfig/createConfigController.js @@ -0,0 +1,102 @@ +angular.module('createConfig', []) +.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService', +function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) { + + $scope.formValues = { + Name: '', + Labels: [], + AccessControlData: new AccessControlFormData() + }; + + $scope.state = { + formValidationError: '' + }; + + $scope.addLabel = function() { + $scope.formValues.Labels.push({ name: '', value: ''}); + }; + + $scope.removeLabel = function(index) { + $scope.formValues.Labels.splice(index, 1); + }; + + function prepareLabelsConfig(config) { + var labels = {}; + $scope.formValues.Labels.forEach(function (label) { + if (label.name && label.value) { + labels[label.name] = label.value; + } + }); + config.Labels = labels; + } + + function prepareConfigData(config) { + // The codemirror editor does not work with ng-model so we need to retrieve + // the value directly from the editor. + var configData = $scope.editor.getValue(); + config.Data = btoa(unescape(encodeURIComponent(configData))); + } + + function prepareConfiguration() { + var config = {}; + config.Name = $scope.formValues.Name; + prepareConfigData(config); + prepareLabelsConfig(config); + return config; + } + + function validateForm(accessControlData, isAdmin) { + $scope.state.formValidationError = ''; + var error = ''; + error = FormValidator.validateAccessControl(accessControlData, isAdmin); + + if (error) { + $scope.state.formValidationError = error; + return false; + } + return true; + } + + $scope.create = function () { + $('#createResourceSpinner').show(); + + var accessControlData = $scope.formValues.AccessControlData; + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + var config = prepareConfiguration(); + + ConfigService.create(config) + .then(function success(data) { + var configIdentifier = data.ID; + var userId = userDetails.ID; + return ResourceControlService.applyResourceControl('config', configIdentifier, userId, accessControlData, []); + }) + .then(function success() { + Notifications.success('Config successfully created'); + $state.go('configs', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create config'); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + }; + + function initView() { + $document.ready(function() { + var webEditorElement = $document[0].getElementById('config-editor', false); + if (webEditorElement) { + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false); + } + }); + } + + initView(); +}]); diff --git a/app/components/createConfig/createconfig.html b/app/components/createConfig/createconfig.html new file mode 100644 index 000000000..5eee991ca --- /dev/null +++ b/app/components/createConfig/createconfig.html @@ -0,0 +1,74 @@ + + + + Configs > Add config + + + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + + add label + +
+ +
+
+
+ name + +
+
+ value + +
+ +
+
+ +
+ + + + + +
+ Actions +
+
+
+ + Cancel + +
+
+ +
+
+
+
+
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index 887fe7cb3..487104a20 100644 --- a/app/components/createService/createServiceController.js +++ b/app/components/createService/createServiceController.js @@ -1,8 +1,8 @@ // @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services. // See app/components/templates/templatesController.js as a reference. angular.module('createService', []) -.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService', -function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) { +.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService', +function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) { $scope.formValues = { Name: '', @@ -28,6 +28,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se UpdateOrder: 'stop-first', FailureAction: 'pause', Secrets: [], + Configs: [], AccessControlData: new AccessControlFormData(), CpuLimit: 0, CpuReservation: 0, @@ -71,6 +72,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $scope.formValues.Volumes.splice(index, 1); }; + $scope.addConfig = function() { + $scope.formValues.Configs.push({}); + }; + + $scope.removeConfig = function(index) { + $scope.formValues.Configs.splice(index, 1); + }; + $scope.addSecret = function() { $scope.formValues.Secrets.push({}); }; @@ -189,10 +198,32 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels); } + function createMountObjectFromVolume(volumeObject, target, readonly) { + return { + Target: target, + Source: volumeObject.Id, + Type: 'volume', + ReadOnly: readonly, + VolumeOptions: { + Labels: volumeObject.Labels, + DriverConfig: { + Name: volumeObject.Driver, + Options: volumeObject.Options + } + } + }; + } + function prepareVolumes(config, input) { input.Volumes.forEach(function (volume) { if (volume.Source && volume.Target) { - config.TaskTemplate.ContainerSpec.Mounts.push(volume); + if (volume.Type !== 'volume') { + config.TaskTemplate.ContainerSpec.Mounts.push(volume); + } else { + var volumeObject = volume.Source; + var mount = createMountObjectFromVolume(volumeObject, volume.Target, volume.ReadOnly); + config.TaskTemplate.ContainerSpec.Mounts.push(mount); + } } }); } @@ -222,6 +253,20 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences); } + function prepareConfigConfig(config, input) { + if (input.Configs) { + var configs = []; + angular.forEach(input.Configs, function(config) { + if (config.model) { + var s = ConfigHelper.configConfig(config.model); + s.File.Name = config.FileName || s.File.Name; + configs.push(s); + } + }); + config.TaskTemplate.ContainerSpec.Configs = configs; + } + } + function prepareSecretConfig(config, input) { if (input.Secrets) { var secrets = []; @@ -294,6 +339,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se prepareVolumes(config, input); prepareNetworks(config, input); prepareUpdateConfig(config, input); + prepareConfigConfig(config, input); prepareSecretConfig(config, input); preparePlacementConfig(config, input); prepareResourcesCpuConfig(config, input); @@ -382,8 +428,9 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $q.all({ volumes: VolumeService.volumes(), - secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], networks: NetworkService.networks(true, true, false, false), + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.30 ? ConfigService.configs() : [], nodes: NodeService.nodes(), settings: SettingsService.publicSettings() }) @@ -391,6 +438,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se $scope.availableVolumes = data.volumes; $scope.availableNetworks = data.networks; $scope.availableSecrets = data.secrets; + $scope.availableConfigs = data.configs; var nodes = data.nodes; initSlidersMaxValuesBasedOnNodeData(nodes); var settings = data.settings; diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index c026df3de..14467cfd0 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -133,6 +133,7 @@
  • Labels
  • Update config
  • Secrets
  • +
  • Configs
  • Resources & Placement
  • @@ -240,9 +241,8 @@
    volume - -
    @@ -442,6 +442,9 @@
    + +
    +
    diff --git a/app/components/createService/includes/config.html b/app/components/createService/includes/config.html new file mode 100644 index 000000000..8083ae2f2 --- /dev/null +++ b/app/components/createService/includes/config.html @@ -0,0 +1,27 @@ +
    +
    +
    + + + add a config + +
    +
    +
    +
    + config + +
    +
    + Path in container + +
    + +
    +
    +
    +
    diff --git a/app/components/createStack/createStackController.js b/app/components/createStack/createStackController.js index 840b81e8c..bb358ee58 100644 --- a/app/components/createStack/createStackController.js +++ b/app/components/createStack/createStackController.js @@ -1,6 +1,6 @@ angular.module('createStack', []) -.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', -function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) { +.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper', +function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) { // Store the editor content when switching builder methods var editorContent = ''; @@ -11,6 +11,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica StackFileContent: '# Define or paste the content of your docker-compose file here', StackFile: null, RepositoryURL: '', + Env: [], RepositoryPath: 'docker-compose.yml', AccessControlData: new AccessControlFormData() }; @@ -20,6 +21,14 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica formValidationError: '' }; + $scope.addEnvironmentVariable = function() { + $scope.formValues.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.formValues.Env.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -34,20 +43,21 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica function createStack(name) { var method = $scope.state.Method; + var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env); if (method === 'editor') { // The codemirror editor does not work with ng-model so we need to retrieve // the value directly from the editor. var stackFileContent = $scope.editor.getValue(); - return StackService.createStackFromFileContent(name, stackFileContent); + return StackService.createStackFromFileContent(name, stackFileContent, env); } else if (method === 'upload') { var stackFile = $scope.formValues.StackFile; - return StackService.createStackFromFileUpload(name, stackFile); + return StackService.createStackFromFileUpload(name, stackFile, env); } else if (method === 'repository') { var gitRepository = $scope.formValues.RepositoryURL; var pathInRepository = $scope.formValues.RepositoryPath; - return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository); + return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository, env); } } @@ -91,7 +101,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica $document.ready(function() { var webEditorElement = $document[0].getElementById('web-editor'); if (webEditorElement) { - $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false); if (value) { $scope.editor.setValue(value); } diff --git a/app/components/createStack/createstack.html b/app/components/createStack/createstack.html index 4845e58e3..fabbbbab7 100644 --- a/app/components/createStack/createstack.html +++ b/app/components/createStack/createstack.html @@ -131,6 +131,36 @@
    +
    + Environment +
    + +
    +
    + + + add environment variable + +
    + +
    +
    +
    + name + +
    +
    + value + +
    + +
    +
    + +
    + diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index ebc2f7501..4eb2a64ad 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -85,7 +85,7 @@
    -
    + -
    +
    diff --git a/app/components/dashboard/dashboardController.js b/app/components/dashboard/dashboardController.js index 4f9d83094..82ca08184 100644 --- a/app/components/dashboard/dashboardController.js +++ b/app/components/dashboard/dashboardController.js @@ -68,6 +68,7 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System $('#loadingViewSpinner').show(); var endpointProvider = $scope.applicationState.endpoint.mode.provider; + var endpointRole = $scope.applicationState.endpoint.mode.role; $q.all([ Container.query({all: 1}).$promise, @@ -75,8 +76,8 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System Volume.query({}).$promise, Network.query({}).$promise, SystemService.info(), - endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [], - endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : [] + endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [], + endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? StackService.stacks(true) : [] ]).then(function (d) { prepareContainerData(d[0]); prepareImageData(d[1]); diff --git a/app/components/endpoints/endpointsController.js b/app/components/endpoints/endpointsController.js index 9a761a164..01e9670e5 100644 --- a/app/components/endpoints/endpointsController.js +++ b/app/components/endpoints/endpointsController.js @@ -1,6 +1,6 @@ angular.module('endpoints', []) -.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination', -function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) { +.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination', +function ($scope, $state, $filter, EndpointService, EndpointProvider, Notifications, Pagination) { $scope.state = { uploadInProgress: false, selectedItemCount: 0, @@ -44,7 +44,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi $scope.addEndpoint = function() { var name = $scope.formValues.Name; - var URL = $scope.formValues.URL; + var URL = $filter('stripprotocol')($scope.formValues.URL); var PublicURL = $scope.formValues.PublicURL; if (PublicURL === '') { PublicURL = URL.split(':')[0]; diff --git a/app/components/images/images.html b/app/components/images/images.html index 8e82ff1a6..818f55b72 100644 --- a/app/components/images/images.html +++ b/app/components/images/images.html @@ -125,10 +125,7 @@ {{ image.Id|truncate:20}} - - Unused - + Unused {{ tag }} diff --git a/app/components/network/networkController.js b/app/components/network/networkController.js index f4ea325e5..87da9d9e8 100644 --- a/app/components/network/networkController.js +++ b/app/components/network/networkController.js @@ -40,12 +40,14 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai var containersInNetwork = []; containers.forEach(function(container) { var containerInNetwork = network.Containers[container.Id]; - containerInNetwork.Id = container.Id; - // Name is not available in Docker 1.9 - if (!containerInNetwork.Name) { - containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]); + if (containerInNetwork) { + containerInNetwork.Id = container.Id; + // Name is not available in Docker 1.9 + if (!containerInNetwork.Name) { + containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]); + } + containersInNetwork.push(containerInNetwork); } - containersInNetwork.push(containerInNetwork); }); $scope.containersInNetwork = containersInNetwork; } @@ -68,7 +70,7 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai }); } else { Container.query({ - filters: {network: [$transition$.params().id]} + filters: { network: [$transition$.params().id] } }, function success(data) { filterContainersInNetwork(network, data); $('#loadingViewSpinner').hide(); diff --git a/app/components/secrets/secrets.html b/app/components/secrets/secrets.html index b274ae777..efd4107b3 100644 --- a/app/components/secrets/secrets.html +++ b/app/components/secrets/secrets.html @@ -16,7 +16,7 @@
    - Add secret + Add secret
    @@ -39,8 +39,8 @@ Created at - - + + diff --git a/app/components/secrets/secretsController.js b/app/components/secrets/secretsController.js index 1aa890504..fc71dd58b 100644 --- a/app/components/secrets/secretsController.js +++ b/app/components/secrets/secretsController.js @@ -1,6 +1,6 @@ angular.module('secrets', []) -.controller('SecretsController', ['$scope', '$transition$', '$state', 'SecretService', 'Notifications', 'Pagination', -function ($scope, $transition$, $state, SecretService, Notifications, Pagination) { +.controller('SecretsController', ['$scope', '$state', 'SecretService', 'Notifications', 'Pagination', +function ($scope, $state, SecretService, Notifications, Pagination) { $scope.state = {}; $scope.state.selectedItemCount = 0; $scope.state.pagination_count = Pagination.getPaginationCount('secrets'); diff --git a/app/components/service/includes/configs.html b/app/components/service/includes/configs.html new file mode 100644 index 000000000..e1eba62cd --- /dev/null +++ b/app/components/service/includes/configs.html @@ -0,0 +1,62 @@ +
    + + + + +
    + Add a config: + + + add config + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    NamePath in containerUIDGIDMode
    {{ config.Name }} + + {{ config.Uid }}{{ config.Gid }}{{ config.Mode }} + +
    No configs associated to this service.
    +
    + + + +
    +
    diff --git a/app/components/service/service.html b/app/components/service/service.html index e11585c62..f54a46b69 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -117,6 +117,7 @@
  • Restart policy
  • Update configuration
  • Service labels
  • +
  • Configs
  • Secrets
  • Tasks
  • @@ -164,6 +165,7 @@
    +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 9ff2cb6e2..bf3f6d514 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,6 +1,6 @@ angular.module('service', []) -.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', -function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', +function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) { $scope.state = {}; $scope.state.pagination_count = Pagination.getPaginationCount('service_tasks'); @@ -59,6 +59,21 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); } }; + $scope.addConfig = function addConfig(service, config) { + if (config && service.ServiceConfigs.filter(function(serviceConfig) { return serviceConfig.Id === config.Id;}).length === 0) { + service.ServiceConfigs.push({ Id: config.Id, Name: config.Name, FileName: config.Name, Uid: '0', Gid: '0', Mode: 292 }); + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.removeConfig = function removeSecret(service, index) { + var removedElement = service.ServiceConfigs.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + } + }; + $scope.updateConfig = function updateConfig(service) { + updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs); + }; $scope.addSecret = function addSecret(service, secret) { if (secret && service.ServiceSecrets.filter(function(serviceSecret) { return serviceSecret.Id === secret.Id;}).length === 0) { service.ServiceSecrets.push({ Id: secret.Id, Name: secret.Name, FileName: secret.Name, Uid: '0', Gid: '0', Mode: 444 }); @@ -193,6 +208,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels); config.TaskTemplate.ContainerSpec.Image = service.Image; config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : []; + config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : []; if (service.Mode === 'replicated') { config.Mode.Replicated.Replicas = service.Replicas; @@ -289,6 +305,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, function translateServiceArrays(service) { service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : []; + service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : []; service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env); service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels); service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels); @@ -323,12 +340,14 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, return $q.all({ tasks: TaskService.tasks({ service: [service.Name] }), nodes: NodeService.nodes(), - secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] + secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], + configs: apiVersion >= 1.30 ? ConfigService.configs() : [] }); }) .then(function success(data) { $scope.tasks = data.tasks; $scope.nodes = data.nodes; + $scope.configs = data.configs; $scope.secrets = data.secrets; // Set max cpu value @@ -350,6 +369,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }) .catch(function error(err) { $scope.secrets = []; + $scope.configs = []; Notifications.error('Failure', err, 'Unable to retrieve service details'); }) .finally(function final() { diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index 339adb3d1..ca5fbbe6b 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -97,19 +97,22 @@ function ($q, $scope, $transition$, $state, Service, ServiceService, ServiceHelp $('#loadServicesSpinner').show(); $q.all({ services: Service.query({}).$promise, - tasks: Task.query({filters: {'desired-state': ['running']}}).$promise, + tasks: Task.query({filters: {'desired-state': ['running','accepted']}}).$promise, nodes: Node.query({}).$promise }) .then(function success(data) { $scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes); $scope.services = data.services.map(function (service) { - var serviceTasks = data.tasks.filter(function (task) { + var runningTasks = data.tasks.filter(function (task) { return task.ServiceID === service.ID && task.Status.State === 'running'; }); + var allTasks = data.tasks.filter(function (task) { + return task.ServiceID === service.ID; + }); var taskNodes = data.nodes.filter(function (node) { return node.Spec.Availability === 'active' && node.Status.State === 'ready'; }); - return new ServiceViewModel(service, serviceTasks, taskNodes); + return new ServiceViewModel(service, runningTasks, allTasks, taskNodes); }); }) .catch(function error(err) { diff --git a/app/components/settings/settingsController.js b/app/components/settings/settingsController.js index cd038dd35..3c122a053 100644 --- a/app/components/settings/settingsController.js +++ b/app/components/settings/settingsController.js @@ -40,6 +40,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ if (!$scope.formValues.customTemplates) { settings.TemplatesURL = DEFAULT_TEMPLATES_URL; } + settings.DisplayExternalContributors = !$scope.formValues.externalContributions; settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts; settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; diff --git a/app/components/settingsAuthentication/settingsAuthentication.html b/app/components/settingsAuthentication/settingsAuthentication.html index 92fd050d7..38506d751 100644 --- a/app/components/settingsAuthentication/settingsAuthentication.html +++ b/app/components/settingsAuthentication/settingsAuthentication.html @@ -66,7 +66,7 @@
    diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index da646ae77..db12a7dca 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -1,13 +1,14 @@ diff --git a/app/components/stack/stack.html b/app/components/stack/stack.html index 8795ce307..c98c6bdd1 100644 --- a/app/components/stack/stack.html +++ b/app/components/stack/stack.html @@ -38,6 +38,36 @@
    +
    + Environment +
    + +
    +
    + + + add environment variable + +
    + +
    +
    +
    + name + +
    +
    + value + +
    + +
    +
    + +
    +
    Actions
    diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js index b483c1cc7..b8147f899 100644 --- a/app/components/stack/stackController.js +++ b/app/components/stack/stackController.js @@ -1,6 +1,6 @@ angular.module('stack', []) -.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', -function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) { +.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper', +function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper) { $scope.deployStack = function () { $('#createResourceSpinner').show(); @@ -8,8 +8,9 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService // The codemirror editor does not work with ng-model so we need to retrieve // the value directly from the editor. var stackFile = $scope.editor.getValue(); + var env = FormHelper.removeInvalidEnvVars($scope.stack.Env); - StackService.updateStack($scope.stack.Id, stackFile) + StackService.updateStack($scope.stack.Id, stackFile, env) .then(function success(data) { Notifications.success('Stack successfully deployed'); $state.reload(); @@ -22,6 +23,14 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService }); }; + $scope.addEnvironmentVariable = function() { + $scope.stack.Env.push({ name: '', value: ''}); + }; + + $scope.removeEnvironmentVariable = function(index) { + $scope.stack.Env.splice(index, 1); + }; + function initView() { $('#loadingViewSpinner').show(); var stackId = $stateParams.id; @@ -48,7 +57,7 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService $document.ready(function() { var webEditorElement = $document[0].getElementById('web-editor'); if (webEditorElement) { - $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement); + $scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false); } }); diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index bb243ef32..d9b8372c7 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -224,8 +224,8 @@ - {{ node.Hostname }} - {{ node.Hostname }} + {{ node.Hostname }} + {{ node.Hostname }} {{ node.Role }} {{ node.CPUs / 1000000000 }} diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 8b7172b17..94ffe752c 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -3,14 +3,79 @@ - + Templates -
    +
    + +
    + + +
    + +
    +
    + -
    +
    + +
    +
    + Information +
    +
    +
    +
    +
    +
    +
    + +
    + Configuration +
    + +
    + +
    + +
    +
    + + +
    + +
    + + + +
    +
    + + + + + +
    +
    + + + {{ state.formValidationError }} +
    +
    + +
    + + + +
    + + +
    @@ -195,20 +260,40 @@
    + +
    +
    + + + add additional entry + +
    + +
    +
    +
    +
    + value + +
    + +
    +
    +
    +
    +
    - + When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the networks view to create one. - - - App templates cannot be deployed as Swarm Mode services for the moment. You can still use them to quickly deploy containers on the Docker host. - {{ state.formValidationError }}
    @@ -218,7 +303,11 @@
    + +
    + +
    @@ -249,53 +338,92 @@
    -
    - -
    -
    - - - - - - - - -
    - - {{ tpl.Title }} - - - - - - -
    - - -
    - - {{ tpl.Description }} - - - {{ tpl.Categories.join(', ') }} - -
    - -
    - +
    +
    +
    + Deployment method +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    -
    -
    - Loading... + +
    +
    + Templates +
    +
    -
    - No templates available. + +
    + +
    +
    + + + + + + + + +
    + + {{ tpl.Title }} + + + + + + +
    + + +
    + + {{ tpl.Description }} + + + {{ tpl.Categories.join(', ') }} + +
    + +
    + +
    + +
    +
    + Loading... +
    +
    + No templates available. +
    -
    +
    -
    diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index dac5df747..8ca34a100 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -1,14 +1,16 @@ angular.module('templates', []) -.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', -function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator, SettingsService) { +.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService', +function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator, SettingsService, StackService) { $scope.state = { selectedTemplate: null, showAdvancedOptions: false, hideDescriptions: $transition$.params().hide_descriptions, formValidationError: '', + showDeploymentSelector: false, filters: { Categories: '!', - Platform: '!' + Platform: '!', + Type: 'container' } }; @@ -34,6 +36,14 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer $scope.state.selectedTemplate.Ports.splice(index, 1); }; + $scope.addExtraHost = function() { + $scope.state.selectedTemplate.Hosts.push(''); + }; + + $scope.removeExtraHost = function(index) { + $scope.state.selectedTemplate.Hosts.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -46,19 +56,7 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer return true; } - $scope.createTemplate = function() { - $('#createContainerSpinner').show(); - - var userDetails = Authentication.getUserDetails(); - var accessControlData = $scope.formValues.AccessControlData; - var isAdmin = userDetails.role === 1 ? true : false; - - if (!validateForm(accessControlData, isAdmin)) { - $('#createContainerSpinner').hide(); - return; - } - - var template = $scope.state.selectedTemplate; + function createContainerFromTemplate(template, userId, accessControlData) { var templateConfiguration = createTemplateConfiguration(template); var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes); var generatedVolumeIds = []; @@ -77,7 +75,6 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer }) .then(function success(data) { var containerIdentifier = data.Id; - var userId = userDetails.ID; return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, generatedVolumeIds); }) .then(function success() { @@ -88,8 +85,59 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer Notifications.error('Failure', err, err.msg); }) .finally(function final() { - $('#createContainerSpinner').hide(); + $('#createResourceSpinner').hide(); }); + } + + function createStackFromTemplate(template, userId, accessControlData) { + var stackName = $scope.formValues.name; + + for (var i = 0; i < template.Env.length; i++) { + var envvar = template.Env[i]; + if (envvar.set) { + envvar.value = envvar.set; + } + } + + StackService.createStackFromGitRepository(stackName, template.Repository.url, template.Repository.stackfile, template.Env) + .then(function success() { + Notifications.success('Stack successfully created'); + }) + .catch(function error(err) { + Notifications.warning('Deployment error', err.err.data.err); + }) + .then(function success(data) { + return ResourceControlService.applyResourceControl('stack', stackName, userId, accessControlData, []); + }) + .then(function success() { + $state.go('stacks', {}, {reload: true}); + }) + .finally(function final() { + $('#createResourceSpinner').hide(); + }); + } + + $scope.createTemplate = function() { + $('#createResourceSpinner').show(); + + var userDetails = Authentication.getUserDetails(); + var userId = userDetails.ID; + var accessControlData = $scope.formValues.AccessControlData; + var isAdmin = userDetails.role === 1 ? true : false; + + if (!validateForm(accessControlData, isAdmin)) { + $('#createResourceSpinner').hide(); + return; + } + + var template = $scope.state.selectedTemplate; + var templatesKey = $scope.templatesKey; + + if (template.Type === 'stack') { + createStackFromTemplate(template, userId, accessControlData); + } else { + createContainerFromTemplate(template, userId, accessControlData); + } }; $scope.unselectTemplate = function() { @@ -144,11 +192,22 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer return containerMapping; } - function initTemplates() { - var templatesKey = $transition$.params().key; - var provider = $scope.applicationState.endpoint.mode.provider; - var apiVersion = $scope.applicationState.endpoint.apiVersion; + $scope.updateCategories = function(templates, type) { + $scope.state.filters.Categories = '!'; + updateCategories(templates, type); + }; + function updateCategories(templates, type) { + var availableCategories = []; + angular.forEach(templates, function(template) { + if (template.Type === type) { + availableCategories = availableCategories.concat(template.Categories); + } + }); + $scope.availableCategories = _.sortBy(_.uniq(availableCategories)); + } + + function initTemplates(templatesKey, type, provider, apiVersion) { $q.all({ templates: TemplateService.getTemplates(templatesKey), containers: ContainerService.containers(0), @@ -161,12 +220,9 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer settings: SettingsService.publicSettings() }) .then(function success(data) { - $scope.templates = data.templates; - var availableCategories = []; - angular.forEach($scope.templates, function(template) { - availableCategories = availableCategories.concat(template.Categories); - }); - $scope.availableCategories = _.sortBy(_.uniq(availableCategories)); + var templates = data.templates; + updateCategories(templates, type); + $scope.templates = templates; $scope.runningContainers = data.containers; $scope.availableVolumes = data.volumes.Volumes; var networks = data.networks; @@ -174,17 +230,33 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer $scope.globalNetworkCount = networks.length; var settings = data.settings; $scope.allowBindMounts = settings.AllowBindMountsForRegularUsers; - var userDetails = Authentication.getUserDetails(); - $scope.isAdmin = userDetails.role === 1 ? true : false; }) .catch(function error(err) { $scope.templates = []; Notifications.error('Failure', err, 'An error occured during apps initialization.'); }) .finally(function final(){ - $('#loadTemplatesSpinner').hide(); + $('#loadingViewSpinner').hide(); }); } - initTemplates(); + function initView() { + var templatesKey = $transition$.params().key; + $scope.templatesKey = templatesKey; + + var userDetails = Authentication.getUserDetails(); + $scope.isAdmin = userDetails.role === 1 ? true : false; + + var endpointMode = $scope.applicationState.endpoint.mode; + var apiVersion = $scope.applicationState.endpoint.apiVersion; + + if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25) { + $scope.state.filters.Type = 'stack'; + $scope.state.showDeploymentSelector = true; + } + + initTemplates(templatesKey, $scope.state.filters.Type, endpointMode.provider, apiVersion); + } + + initView(); }]); diff --git a/app/components/volume/volume.html b/app/components/volume/volume.html index 15ebfc41e..2b9d40af4 100644 --- a/app/components/volume/volume.html +++ b/app/components/volume/volume.html @@ -73,3 +73,26 @@
    +
    +
    + + + + + + + + + + + + + + + + +
    Container NameMounted AtRead-only
    {{ container | containername }}{{ container.volumeData.Destination }}{{ !container.volumeData.RW }}
    +
    +
    +
    +
    diff --git a/app/components/volume/volumeController.js b/app/components/volume/volumeController.js index 16c165980..12b7053a5 100644 --- a/app/components/volume/volumeController.js +++ b/app/components/volume/volumeController.js @@ -1,6 +1,6 @@ angular.module('volume', []) -.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'Notifications', -function ($scope, $state, $transition$, VolumeService, Notifications) { +.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', +function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) { $scope.removeVolume = function removeVolume() { $('#loadingViewSpinner').show(); @@ -17,12 +17,27 @@ function ($scope, $state, $transition$, VolumeService, Notifications) { }); }; + function getVolumeDataFromContainer(container, volumeId) { + return container.Mounts.find(function(volume) { + return volume.Name === volumeId; + }); + } + function initView() { $('#loadingViewSpinner').show(); VolumeService.volume($transition$.params().id) .then(function success(data) { var volume = data; $scope.volume = volume; + var containerFilter = { volume: [volume.Id] }; + return ContainerService.containers(1, containerFilter); + }) + .then(function success(data) { + var containers = data.map(function(container) { + container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id); + return container; + }); + $scope.containersUsingVolume = containers; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve volume details'); diff --git a/app/helpers/configHelper.js b/app/helpers/configHelper.js new file mode 100644 index 000000000..f47eacc7e --- /dev/null +++ b/app/helpers/configHelper.js @@ -0,0 +1,34 @@ +angular.module('portainer.helpers') +.factory('ConfigHelper', [function ConfigHelperFactory() { + 'use strict'; + return { + flattenConfig: function(config) { + if (config) { + return { + Id: config.ConfigID, + Name: config.ConfigName, + FileName: config.File.Name, + Uid: config.File.UID, + Gid: config.File.GID, + Mode: config.File.Mode + }; + } + return {}; + }, + configConfig: function(config) { + if (config) { + return { + ConfigID: config.Id, + ConfigName: config.Name, + File: { + Name: config.FileName || config.Name, + UID: config.Uid || '0', + GID: config.Gid || '0', + Mode: config.Mode || 292 + } + }; + } + return {}; + } + }; +}]); diff --git a/app/helpers/formHelper.js b/app/helpers/formHelper.js new file mode 100644 index 000000000..7cb3456f1 --- /dev/null +++ b/app/helpers/formHelper.js @@ -0,0 +1,18 @@ +angular.module('portainer.helpers') +.factory('FormHelper', [function FormHelperFactory() { + 'use strict'; + var helper = {}; + + helper.removeInvalidEnvVars = function(env) { + for (var i = env.length - 1; i >= 0; i--) { + var envvar = env[i]; + if (!envvar.value || !envvar.name) { + env.splice(i, 1); + } + } + + return env; + }; + + return helper; +}]); diff --git a/app/helpers/secretHelper.js b/app/helpers/secretHelper.js index 9c0f3d65b..afd3b1a56 100644 --- a/app/helpers/secretHelper.js +++ b/app/helpers/secretHelper.js @@ -22,9 +22,9 @@ angular.module('portainer.helpers') SecretName: secret.Name, File: { Name: secret.FileName, - UID: '0', - GID: '0', - Mode: 444 + UID: secret.Uid || '0', + GID: secret.Gid || '0', + Mode: secret.Mode || 444 } }; } diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index a4f626fd5..d6ad6bc63 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -15,7 +15,8 @@ angular.module('portainer.helpers') }, PortBindings: {}, Binds: [], - Privileged: false + Privileged: false, + ExtraHosts: [] }, Volumes: {} }; diff --git a/app/models/api/stack.js b/app/models/api/stack.js index 3d4645913..30b130943 100644 --- a/app/models/api/stack.js +++ b/app/models/api/stack.js @@ -2,6 +2,7 @@ function StackViewModel(data) { this.Id = data.Id; this.Name = data.Name; this.Checked = false; + this.Env = data.Env; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } diff --git a/app/models/api/stackTemplate.js b/app/models/api/stackTemplate.js new file mode 100644 index 000000000..728d98421 --- /dev/null +++ b/app/models/api/stackTemplate.js @@ -0,0 +1,11 @@ +function StackTemplateViewModel(data) { + this.Type = data.type; + this.Title = data.title; + this.Description = data.description; + this.Note = data.note; + this.Categories = data.categories ? data.categories : []; + this.Platform = data.platform ? data.platform : 'undefined'; + this.Logo = data.logo; + this.Repository = data.repository; + this.Env = data.env ? data.env : []; +} diff --git a/app/models/api/template.js b/app/models/api/template.js index 0123f92e4..76a77d961 100644 --- a/app/models/api/template.js +++ b/app/models/api/template.js @@ -1,4 +1,5 @@ function TemplateViewModel(data) { + this.Type = data.type; this.Title = data.title; this.Description = data.description; this.Note = data.note; @@ -42,4 +43,5 @@ function TemplateViewModel(data) { }; }); } + this.Hosts = data.hosts ? data.hosts : []; } diff --git a/app/models/docker/config.js b/app/models/docker/config.js new file mode 100644 index 000000000..214909b5a --- /dev/null +++ b/app/models/docker/config.js @@ -0,0 +1,15 @@ +function ConfigViewModel(data) { + this.Id = data.ID; + this.CreatedAt = data.CreatedAt; + this.UpdatedAt = data.UpdatedAt; + this.Version = data.Version.Index; + this.Name = data.Spec.Name; + this.Labels = data.Spec.Labels; + this.Data = atob(data.Spec.Data); + + if (data.Portainer) { + if (data.Portainer.ResourceControl) { + this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); + } + } +} diff --git a/app/models/docker/container.js b/app/models/docker/container.js index 8c2d112f5..c37f2fe93 100644 --- a/app/models/docker/container.js +++ b/app/models/docker/container.js @@ -7,6 +7,7 @@ function ContainerViewModel(data) { if (data.NetworkSettings && !_.isEmpty(data.NetworkSettings.Networks)) { this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; } + this.NetworkSettings = data.NetworkSettings; this.Image = data.Image; this.ImageID = data.ImageID; this.Command = data.Command; diff --git a/app/models/docker/containerDetails.js b/app/models/docker/containerDetails.js index 63945ff41..eae58c105 100644 --- a/app/models/docker/containerDetails.js +++ b/app/models/docker/containerDetails.js @@ -9,6 +9,7 @@ function ContainerDetailsViewModel(data) { this.Image = data.Image; this.Config = data.Config; this.HostConfig = data.HostConfig; + this.Mounts = data.Mounts; if (data.Portainer) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); diff --git a/app/models/docker/service.js b/app/models/docker/service.js index 28d8609ba..c83779243 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -1,4 +1,4 @@ -function ServiceViewModel(data, runningTasks, nodes) { +function ServiceViewModel(data, runningTasks, allTasks, nodes) { this.Model = data; this.Id = data.ID; this.Tasks = []; @@ -12,8 +12,8 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Replicas = data.Spec.Mode.Replicated.Replicas; } else { this.Mode = 'global'; - if (nodes) { - this.Replicas = nodes.length; + if (allTasks) { + this.Replicas = allTasks.length; } } if (runningTasks) { @@ -69,6 +69,7 @@ function ServiceViewModel(data, runningTasks, nodes) { this.Hosts = containerSpec.Hosts; this.DNSConfig = containerSpec.DNSConfig; this.Secrets = containerSpec.Secrets; + this.Configs = containerSpec.Configs; } if (data.Endpoint) { this.Ports = data.Endpoint.Ports; diff --git a/app/rest/docker/config.js b/app/rest/docker/config.js new file mode 100644 index 000000000..330692fbe --- /dev/null +++ b/app/rest/docker/config.js @@ -0,0 +1,12 @@ +angular.module('portainer.rest') +.factory('Config', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ConfigFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/configs/:id/:action', { + endpointId: EndpointProvider.endpointID + }, { + get: { method: 'GET', params: { id: '@id' } }, + query: { method: 'GET', isArray: true }, + create: { method: 'POST', params: { action: 'create' } }, + remove: { method: 'DELETE', params: { id: '@id' } } + }); +}]); diff --git a/app/rest/docker/container.js b/app/rest/docker/container.js index d8786cb03..25ab3b2da 100644 --- a/app/rest/docker/container.js +++ b/app/rest/docker/container.js @@ -40,6 +40,9 @@ angular.module('portainer.rest') exec: { method: 'POST', params: {id: '@id', action: 'exec'}, transformResponse: genericHandler + }, + inspect: { + method: 'GET', params: { id: '@id', action: 'json' } } }); }]); diff --git a/app/routes.js b/app/routes.js index 0232c3d6d..265096184 100644 --- a/app/routes.js +++ b/app/routes.js @@ -26,6 +26,32 @@ function configureRoutes($stateProvider) { requiresLogin: false } }) + .state('configs', { + url: '^/configs/', + views: { + 'content@': { + templateUrl: 'app/components/configs/configs.html', + controller: 'ConfigsController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) + .state('config', { + url: '^/config/:id/', + views: { + 'content@': { + templateUrl: 'app/components/config/config.html', + controller: 'ConfigController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('containers', { parent: 'root', url: '/containers/', @@ -105,6 +131,19 @@ function configureRoutes($stateProvider) { } } }) + .state('inspect', { + url: '^/containers/:id/inspect', + views: { + 'content@': { + templateUrl: 'app/components/containerInspect/containerInspect.html', + controller: 'ContainerInspectController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('dashboard', { parent: 'root', url: '/dashboard', @@ -143,6 +182,19 @@ function configureRoutes($stateProvider) { } } }) + .state('actions.create.config', { + url: '/config', + views: { + 'content@': { + templateUrl: 'app/components/createConfig/createconfig.html', + controller: 'CreateConfigController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }) .state('actions.create.container', { url: '/container/:from', views: { diff --git a/app/services/api/stackService.js b/app/services/api/stackService.js index 6ab6e7f8a..e206b620d 100644 --- a/app/services/api/stackService.js +++ b/app/services/api/stackService.js @@ -100,13 +100,19 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromFileContent = function(name, stackFileContent) { + service.createStackFromFileContent = function(name, stackFileContent, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$promise; + var payload = { + Name: name, + SwarmID: swarm.Id, + StackFileContent: stackFileContent, + Env: env + }; + return Stack.create({ method: 'string' }, payload).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -118,13 +124,20 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromGitRepository = function(name, gitRepository, pathInRepository) { + service.createStackFromGitRepository = function(name, gitRepository, pathInRepository, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$promise; + var payload = { + Name: name, + SwarmID: swarm.Id, + GitRepository: gitRepository, + PathInRepository: pathInRepository, + Env: env + }; + return Stack.create({ method: 'repository' }, payload).$promise; }) .then(function success(data) { deferred.resolve(data); @@ -136,13 +149,13 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.createStackFromFileUpload = function(name, stackFile) { + service.createStackFromFileUpload = function(name, stackFile, env) { var deferred = $q.defer(); SwarmService.swarm() .then(function success(data) { var swarm = data; - return FileUploadService.createStack(name, swarm.Id, stackFile); + return FileUploadService.createStack(name, swarm.Id, stackFile, env); }) .then(function success(data) { deferred.resolve(data.data); @@ -154,8 +167,8 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.updateStack = function(id, stackFile) { - return Stack.update({ id: id, StackFileContent: stackFile }).$promise; + service.updateStack = function(id, stackFile, env) { + return Stack.update({ id: id, StackFileContent: stackFile, Env: env }).$promise; }; return service; diff --git a/app/services/codeMirror.js b/app/services/codeMirror.js index b5807f87e..e4cf3b821 100644 --- a/app/services/codeMirror.js +++ b/app/services/codeMirror.js @@ -2,8 +2,11 @@ angular.module('portainer.services') .factory('CodeMirrorService', function CodeMirrorService() { 'use strict'; - var codeMirrorOptions = { - lineNumbers: true, + var codeMirrorGenericOptions = { + lineNumbers: true + }; + + var codeMirrorYAMLOptions = { mode: 'text/x-yaml', gutters: ['CodeMirror-lint-markers'], lint: true @@ -11,8 +14,18 @@ angular.module('portainer.services') var service = {}; - service.applyCodeMirrorOnElement = function(element) { - var cm = CodeMirror.fromTextArea(element, codeMirrorOptions); + service.applyCodeMirrorOnElement = function(element, yamlLint, readOnly) { + var options = codeMirrorGenericOptions; + + if (yamlLint) { + options = codeMirrorYAMLOptions; + } + + if (readOnly) { + options.readOnly = true; + } + + var cm = CodeMirror.fromTextArea(element, options); cm.setSize('100%', 500); return cm; }; diff --git a/app/services/docker/configService.js b/app/services/docker/configService.js new file mode 100644 index 000000000..530c689e7 --- /dev/null +++ b/app/services/docker/configService.js @@ -0,0 +1,61 @@ +angular.module('portainer.services') +.factory('ConfigService', ['$q', 'Config', function ConfigServiceFactory($q, Config) { + 'use strict'; + var service = {}; + + service.config = function(configId) { + var deferred = $q.defer(); + + Config.get({id: configId}).$promise + .then(function success(data) { + var config = new ConfigViewModel(data); + deferred.resolve(config); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve config details', err: err }); + }); + + return deferred.promise; + }; + + service.configs = function() { + var deferred = $q.defer(); + + Config.query({}).$promise + .then(function success(data) { + var configs = data.map(function (item) { + return new ConfigViewModel(item); + }); + deferred.resolve(configs); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve configs', err: err }); + }); + + return deferred.promise; + }; + + service.remove = function(configId) { + var deferred = $q.defer(); + + Config.remove({ id: configId }).$promise + .then(function success(data) { + if (data.message) { + deferred.reject({ msg: data.message }); + } else { + deferred.resolve(); + } + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to remove config', err: err }); + }); + + return deferred.promise; + }; + + service.create = function(config) { + return Config.create(config).$promise; + }; + + return service; +}]); diff --git a/app/services/docker/containerService.js b/app/services/docker/containerService.js index 21bd1c295..2b09aedfa 100644 --- a/app/services/docker/containerService.js +++ b/app/services/docker/containerService.js @@ -20,8 +20,7 @@ angular.module('portainer.services') service.containers = function(all, filters) { var deferred = $q.defer(); - - Container.query({ all: all, filters: filters ? filters : {} }).$promise + Container.query({ all : all, filters: filters }).$promise .then(function success(data) { var containers = data.map(function (item) { return new ContainerViewModel(item); @@ -140,18 +139,11 @@ angular.module('portainer.services') }; service.containerTop = function(id) { - var deferred = $q.defer(); + return Container.top({id: id}).$promise; + }; - Container.top({id: id}).$promise - .then(function success(data) { - var containerTop = data; - deferred.resolve(containerTop); - }) - .catch(function error(err) { - deferred.reject(err); - }); - - return deferred.promise; + service.inspect = function(id) { + return Container.inspect({id: id}).$promise; }; return service; diff --git a/app/services/fileUpload.js b/app/services/fileUpload.js index 0f3f46643..da121eec6 100644 --- a/app/services/fileUpload.js +++ b/app/services/fileUpload.js @@ -8,9 +8,9 @@ angular.module('portainer.services') return Upload.upload({ url: url, data: { file: file }}); } - service.createStack = function(stackName, swarmId, file) { + service.createStack = function(stackName, swarmId, file, env) { var endpointID = EndpointProvider.endpointID(); - return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } }); + return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId, Env: Upload.json(env) } }); }; service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { diff --git a/app/services/templateService.js b/app/services/templateService.js index a28e866ea..89694da59 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -9,7 +9,9 @@ angular.module('portainer.services') .then(function success(data) { var templates = data.map(function (tpl, idx) { var template; - if (key === 'linuxserver.io') { + if (tpl.type === 'stack') { + template = new StackTemplateViewModel(tpl); + } else if (tpl.type === 'container' && key === 'linuxserver.io') { template = new TemplateLSIOViewModel(tpl); } else { template = new TemplateViewModel(tpl); @@ -40,6 +42,7 @@ angular.module('portainer.services') configuration.HostConfig.NetworkMode = network.Name; configuration.HostConfig.Privileged = template.Privileged; configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy }; + configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : []; configuration.name = containerName; configuration.Hostname = containerName; configuration.Image = template.Image; diff --git a/assets/css/app.css b/assets/css/app.css index 9fda8ca7a..3f6979afb 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -7,10 +7,6 @@ html, body, #content-wrapper, .page-content, #view { white-space: normal !important; } -.btn-group button { - margin: 3px; -} - .messages { max-height: 50px; overflow-x: hidden; @@ -282,13 +278,19 @@ a[ng-click]{ } ul.sidebar { - bottom: 40px; + position: relative; + overflow: hidden; + flex-shrink: 0; } ul.sidebar .sidebar-title { height: auto; } +ul.sidebar .sidebar-list a { + font-size: 14px; +} + ul.sidebar .sidebar-list a.active { color: #fff; text-indent: 22px; @@ -296,7 +298,32 @@ ul.sidebar .sidebar-list a.active { background: #2d3e63; } -.sidebar-footer .logo { +.sidebar-header { + height: 60px; + list-style: none; + text-indent: 20px; + font-size: 18px; + background: #2d3e63; +} + +.sidebar-header a { color: #fff; } +.sidebar-header a:hover {text-decoration: none; } + +.sidebar-header .menu-icon { + float: right; + padding-right: 28px; + line-height: 60px; +} + +#page-wrapper:not(.open) .sidebar-footer-content { + display: none; +} + +.sidebar-footer-content { + text-align: center; +} + +.sidebar-footer-content .logo { width: 100%; max-width: 100px; height: 100%; @@ -304,12 +331,26 @@ ul.sidebar .sidebar-list a.active { margin: 2px 0 2px 20px; } -.sidebar-footer .version { +.sidebar-footer-content .version { font-size: 11px; margin: 11px 20px 0 7px; color: #fff; } +#sidebar-wrapper { + display: flex; + flex-flow: column; +} + +.sidebar-content { + display: flex; + flex-direction: column; + justify-content: space-between; + overflow-y: auto; + overflow-x: hidden; + height: 100%; +} + #image-layers .btn{ padding: 0; } @@ -331,6 +372,46 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { background: #2d3e63; } +@media (max-height: 683px) { + ul.sidebar .sidebar-title { + line-height: 28px; + } + ul.sidebar .sidebar-title .form-control { + height: 28px; + padding: 4px 8px; + } + ul.sidebar .sidebar-list { + height: 28px; + } + ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { + font-size: 12px; + line-height: 28px; + } + ul.sidebar .sidebar-list .menu-icon { + line-height: 28px; + } +} + +@media(min-height: 684px) and (max-height: 850px) { + ul.sidebar .sidebar-title { + line-height: 30px; + } + ul.sidebar .sidebar-title .form-control { + height: 30px; + padding: 5px 10px; + } + ul.sidebar .sidebar-list { + height: 30px; + } + ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { + font-size: 12px; + line-height: 30px; + } + ul.sidebar .sidebar-list .menu-icon { + line-height: 30px; + } +} + @media(min-width: 768px) and (max-width: 992px) { .margin-sm-top { margin-top: 5px; @@ -615,3 +696,20 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { font-family: monospace; font-weight: 600; } + +/* json-tree */ + +json-tree { + font-size: 13px; + color: #30426a; +} +json-tree .key { + color: #738bc0; + padding-right: 5px; +} + +json-tree .branch-preview { + font-style: normal; + font-size: 11px; + opacity: .5; +} diff --git a/assets/ico/android-chrome-192x192.png b/assets/ico/android-chrome-192x192.png index abf81c516..6d31b95f2 100644 Binary files a/assets/ico/android-chrome-192x192.png and b/assets/ico/android-chrome-192x192.png differ diff --git a/assets/ico/android-chrome-256x256.png b/assets/ico/android-chrome-256x256.png index 8770d7d3b..a7eb8da10 100644 Binary files a/assets/ico/android-chrome-256x256.png and b/assets/ico/android-chrome-256x256.png differ diff --git a/assets/ico/apple-touch-icon.png b/assets/ico/apple-touch-icon.png index 6ca3086cd..b5aa4b2e7 100644 Binary files a/assets/ico/apple-touch-icon.png and b/assets/ico/apple-touch-icon.png differ diff --git a/assets/ico/browserconfig.xml b/assets/ico/browserconfig.xml index f9aefe5b5..44751e9d4 100644 --- a/assets/ico/browserconfig.xml +++ b/assets/ico/browserconfig.xml @@ -2,7 +2,7 @@ - + #2d89ef diff --git a/assets/ico/favicon-16x16.png b/assets/ico/favicon-16x16.png index 192339c24..239474fb2 100644 Binary files a/assets/ico/favicon-16x16.png and b/assets/ico/favicon-16x16.png differ diff --git a/assets/ico/favicon-32x32.png b/assets/ico/favicon-32x32.png index 25c2aeeb1..ab08671dc 100644 Binary files a/assets/ico/favicon-32x32.png and b/assets/ico/favicon-32x32.png differ diff --git a/assets/ico/manifest.json b/assets/ico/manifest.json index e753aeb6b..843e03e83 100644 --- a/assets/ico/manifest.json +++ b/assets/ico/manifest.json @@ -2,12 +2,12 @@ "name": "Portainer", "icons": [ { - "src": "/ico/android-chrome-192x192.png", + "src": "ico/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/ico/android-chrome-256x256.png", + "src": "ico/android-chrome-256x256.png", "sizes": "256x256", "type": "image/png" } diff --git a/assets/ico/mstile-150x150.png b/assets/ico/mstile-150x150.png index f3670168a..60643c1ac 100644 Binary files a/assets/ico/mstile-150x150.png and b/assets/ico/mstile-150x150.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png index 82f5a35d4..f0ba92c53 100644 Binary files a/assets/images/logo.png and b/assets/images/logo.png differ diff --git a/assets/images/logo_ico.png b/assets/images/logo_ico.png index c233495c2..b4bfd2924 100644 Binary files a/assets/images/logo_ico.png and b/assets/images/logo_ico.png differ diff --git a/assets/images/logo_small.png b/assets/images/logo_small.png index 2cd37301a..636f43ab9 100644 Binary files a/assets/images/logo_small.png and b/assets/images/logo_small.png differ diff --git a/bower.json b/bower.json index bf9bd43fa..faa85bf65 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "portainer", - "version": "1.15.0", + "version": "1.15.1", "homepage": "https://github.com/portainer/portainer", "authors": [ "Anthony Lapenna " @@ -34,6 +34,7 @@ "angular-utils-pagination": "~0.11.1", "angular-local-storage": "~0.5.2", "angular-jwt": "~0.1.8", + "angular-json-tree": "1.0.1", "angular-google-analytics": "~1.1.9", "bootstrap": "~3.3.6", "filesize": "~3.3.0", diff --git a/build.sh b/build.sh index 77271abe9..e8d603292 100755 --- a/build.sh +++ b/build.sh @@ -42,7 +42,7 @@ else if [ `echo "$@" | cut -c1-4` == 'echo' ]; then bash -c "$@"; else - build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64' + build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le linux-s390x darwin-amd64 windows-amd64' exit 0 fi fi diff --git a/codefresh.yml b/codefresh.yml index 25413098b..c669dbaef 100644 --- a/codefresh.yml +++ b/codefresh.yml @@ -18,9 +18,17 @@ steps: - grunt build-webapp - mv api/cmd/portainer/portainer dist/ + download_docker_binary: + image: busybox + working_directory: ${{build_frontend}} + commands: + - wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz + - tar -xf /tmp/docker-binaries.tgz -C /tmp + - mv /tmp/docker/docker dist/ + build_image: type: build - working_directory: ${{build_frontend}} + working_directory: ${{download_docker_binary}} dockerfile: ./build/linux/Dockerfile image_name: portainer/portainer tag: ${{CF_BRANCH}} diff --git a/distribution/portainer.service b/distribution/portainer.service new file mode 100644 index 000000000..557784441 --- /dev/null +++ b/distribution/portainer.service @@ -0,0 +1,17 @@ +[Unit] +Description=Portainer.io management ui +After=docker.service +Wants=docker.service +Wants=docker-latest.service + +[Service] +Type=simple +Restart=always +RestartSec=3 +Environment=ASSETS=/usr/share/portainer +Environment=DBFILES=/var/lib/portainer +EnvironmentFile=-/etc/sysconfig/%p +ExecStart=/usr/sbin/portainer -a $ASSETS -d $DBFILES + +[Install] +WantedBy=multi-user.target diff --git a/distribution/portainer.spec b/distribution/portainer.spec new file mode 100644 index 000000000..a2d06e102 --- /dev/null +++ b/distribution/portainer.spec @@ -0,0 +1,96 @@ +Name: portainer +Version: 1.15.1 +Release: 0 +License: Zlib +Summary: A lightweight docker management UI +Url: https://portainer.io +Group: BLAH +Source0: https://github.com/portainer/portainer/releases/download/%{version}/portainer-%{version}-linux-amd64.tar.gz +Source1: portainer.service +BuildRoot: %{_tmppath}/%{name}-%{version}-build +%if 0%{?suse_version} +BuildRequires: help2man +%endif +Requires: docker +%{?systemd_requires} +BuildRequires: systemd + +%description +Portainer is a lightweight management UI which allows you to easily manage +your different Docker environments (Docker hosts or Swarm clusters). +Portainer is meant to be as simple to deploy as it is to use. +It consists of a single container that can run on any Docker engine +(can be deployed as Linux container or a Windows native container). +Portainer allows you to manage your Docker containers, images, volumes, +networks and more ! It is compatible with the standalone Docker engine and with Docker Swarm mode. + +%prep +%setup -qn portainer + +%build +%if 0%{?suse_version} +help2man -N --no-discard-stderr ./portainer > portainer.1 +%endif + +%install +# Create directory structure +install -D -m 0755 portainer %{buildroot}%{_sbindir}/portainer +install -d -m 0755 %{buildroot}%{_datadir}/portainer +install -d -m 0755 %{buildroot}%{_localstatedir}/lib/portainer +install -D -m 0644 %{S:1} %{buildroot}%{_unitdir}/portainer.service +%if 0%{?suse_version} +install -D -m 0644 portainer.1 %{buildroot}%{_mandir}/man1/portainer.1 +( cd %{buildroot}%{_sbindir} ; ln -s service rcportainer ) +%endif +# populate +# don't install docker binary with package use system wide installed one +for src in css fonts ico images index.html js;do + cp -a $src %{buildroot}%{_datadir}/portainer/ +done + +%pre +%if 0%{?suse_version} +%service_add_pre portainer.service +#%%else # this does not work on rhel 7? +#%%systemd_pre portainer.service +true +%endif + +%preun +%if 0%{?suse_version} +%service_del_preun portainer.service +%else +%systemd_preun portainer.service +%endif + +%post +%if 0%{?suse_version} +%service_add_post portainer.service +%else +%systemd_post portainer.service +%endif + +%postun +%if 0%{?suse_version} +%service_del_postun portainer.service +%else +%systemd_postun_with_restart portainer.service +%endif + + +%files +%defattr(-,root,root) +%{_sbindir}/portainer +%dir %{_datadir}/portainer +%{_datadir}/portainer/css +%{_datadir}/portainer/fonts +%{_datadir}/portainer/ico +%{_datadir}/portainer/images +%{_datadir}/portainer/index.html +%{_datadir}/portainer/js +%dir %{_localstatedir}/lib/portainer/ +%{_unitdir}/portainer.service +%if 0%{?suse_version} +%{_mandir}/man1/portainer.1* +%{_sbindir}/rcportainer +%endif \ No newline at end of file diff --git a/gruntfile.js b/gruntfile.js index d4ff5fede..d7ae868d2 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -1,11 +1,15 @@ var autoprefixer = require('autoprefixer'); var cssnano = require('cssnano'); var loadGruntTasks = require('load-grunt-tasks'); +var os = require('os'); +var arch = os.arch(); +if ( arch === 'x64' ) arch = 'amd64'; module.exports = function (grunt) { - loadGruntTasks(grunt); - grunt.loadNpmTasks('gruntify-eslint'); + loadGruntTasks(grunt, { + pattern: ['grunt-*', 'gruntify-*'] + }); grunt.registerTask('default', ['eslint', 'build']); grunt.registerTask('before-copy', [ @@ -33,8 +37,8 @@ module.exports = function (grunt) { grunt.registerTask('build', [ 'config:dev', 'clean:app', - 'shell:buildBinary:linux:amd64', - 'shell:downloadDockerBinary:linux:amd64', + 'shell:buildBinary:linux:' + arch, + 'shell:downloadDockerBinary:linux:' + arch, 'vendor:regular', 'html2js', 'useminPrepare:dev', @@ -53,7 +57,7 @@ module.exports = function (grunt) { // Project configuration. grunt.initConfig({ - distdir: 'dist', + distdir: 'dist/public', shippedDockerVersion: '17.09.0-ce', pkg: grunt.file.readJSON('package.json'), config: { @@ -68,8 +72,8 @@ module.exports = function (grunt) { css: ['assets/css/app.css'] }, clean: { - all: ['<%= distdir %>/*'], - app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*', '!<%= distdir %>/docker*'], + all: ['<%= distdir %>/../*'], + app: ['<%= distdir %>/*', '!<%= distdir %>/../portainer*', '!<%= distdir %>/../docker*'], tmpl: ['<%= distdir %>/templates'], tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css'] }, @@ -89,7 +93,8 @@ module.exports = function (grunt) { release: { src: '<%= src.html %>', options: { - root: '<%= distdir %>' + root: '<%= distdir %>', + dest: '<%= distdir %>' } } }, @@ -183,7 +188,7 @@ module.exports = function (grunt) { run: { command: [ 'docker rm -f portainer', - 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app' + 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics' ].join(';') }, downloadDockerBinary: { diff --git a/index.html b/index.html index f120e0164..1949e5459 100644 --- a/index.html +++ b/index.html @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/package.json b/package.json index 0f48ef054..eb476ea2a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.15.0", + "version": "1.15.1", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" diff --git a/vendor.yml b/vendor.yml index c052c018d..134914a7d 100644 --- a/vendor.yml +++ b/vendor.yml @@ -47,6 +47,7 @@ css: - bower_components/angularjs-slider/dist/rzslider.css - bower_components/codemirror/lib/codemirror.css - bower_components/codemirror/addon/lint/lint.css + - bower_components/angular-json-tree/dist/angular-json-tree.css minified: - bower_components/bootstrap/dist/css/bootstrap.min.css - bower_components/rdash-ui/dist/css/rdash.min.css @@ -58,6 +59,7 @@ css: - bower_components/angularjs-slider/dist/rzslider.min.css - bower_components/codemirror/lib/codemirror.css - bower_components/codemirror/addon/lint/lint.css + - bower_components/angular-json-tree/dist/angular-json-tree.css angular: regular: - bower_components/angular/angular.js @@ -74,6 +76,7 @@ angular: - bower_components/ng-file-upload/ng-file-upload.js - bower_components/angularjs-slider/dist/rzslider.js - bower_components/angular-multi-select/isteven-multi-select.js + - bower_components/angular-json-tree/dist/angular-json-tree.js minified: - bower_components/angular/angular.min.js - bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js @@ -89,3 +92,4 @@ angular: - bower_components/ng-file-upload/ng-file-upload.min.js - bower_components/angularjs-slider/dist/rzslider.min.js - bower_components/angular-multi-select/isteven-multi-select.js + - bower_components/angular-json-tree/dist/angular-json-tree.min.js