diff --git a/api/cli/cli.go b/api/cli/cli.go index a343195f4..d1bace6a3 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -31,12 +31,12 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { kingpin.Version(version) flags := &portainer.CLIFlags{ - Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), - ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(), - SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(), Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), + CheckHealth: kingpin.Flag("check-health", "GET http://localhost:/api/health endpoint").Default(defaultCheckHealth).Short('c').Bool(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), + Endpoint: kingpin.Flag("host", "Dockerd endpoint").Short('H').String(), + ExternalEndpoints: kingpin.Flag("external-endpoints", "Path to a file defining available endpoints").String(), NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), @@ -46,6 +46,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(), SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), + SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), // Deprecated flags diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 2d350c1c7..2c913ad84 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -6,6 +6,7 @@ const ( defaultBindAddress = ":9000" defaultDataDirectory = "/data" defaultAssetsDirectory = "./" + defaultCheckHealth = "false" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index a94657258..9971063b6 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -4,6 +4,7 @@ const ( defaultBindAddress = ":9000" defaultDataDirectory = "C:\\data" defaultAssetsDirectory = "./" + defaultCheckHealth = "false" defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 7c956a05c..efa51c1f3 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -7,13 +7,14 @@ import ( "github.com/portainer/portainer/cron" "github.com/portainer/portainer/crypto" "github.com/portainer/portainer/exec" - "github.com/portainer/portainer/file" + "github.com/portainer/portainer/filesystem" "github.com/portainer/portainer/git" "github.com/portainer/portainer/http" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" "log" + "os" ) func initCLI() *portainer.CLIFlags { @@ -31,7 +32,7 @@ func initCLI() *portainer.CLIFlags { } func initFileService(dataStorePath string) portainer.FileService { - fileService, err := file.NewService(dataStorePath, "") + fileService, err := filesystem.NewService(dataStorePath, "") if err != nil { log.Fatal(err) } @@ -171,6 +172,19 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService func main() { flags := initCLI() + if *flags.CheckHealth { + statuscode, err := http.HealthCheck(*flags.Addr) + if err == nil { + if statuscode == 200 { + log.Println(*flags.Addr, ": Online - response:", statuscode) + os.Exit(0) + } else { + log.Fatal(*flags.Addr, ": Error - response:", statuscode) + } + } + log.Fatal("Connection error:", err.Error()) + } + fileService := initFileService(*flags.Data) store := initStore(*flags.Data) diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index 7e418d70f..8ba6b357e 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -54,10 +54,15 @@ func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error { } // Deploy executes the docker stack deploy command. -func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error { +func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, 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) + + if prune { + args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + } else { + args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + } env := make([]string, 0) for _, envvar := range stack.Env { diff --git a/api/file/file.go b/api/filesystem/filesystem.go similarity index 99% rename from api/file/file.go rename to api/filesystem/filesystem.go index 03de12d23..236845410 100644 --- a/api/file/file.go +++ b/api/filesystem/filesystem.go @@ -1,4 +1,4 @@ -package file +package filesystem import ( "bytes" diff --git a/api/http/handler/settings.go b/api/http/handler/settings.go index 40b14977b..bef01db91 100644 --- a/api/http/handler/settings.go +++ b/api/http/handler/settings.go @@ -5,7 +5,7 @@ import ( "github.com/asaskevich/govalidator" "github.com/portainer/portainer" - "github.com/portainer/portainer/file" + "github.com/portainer/portainer/filesystem" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/security" @@ -138,11 +138,11 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http } if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA) + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath } else { settings.LDAPSettings.TLSConfig.TLSCACertPath = "" - err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath) + err := handler.FileService.DeleteTLSFiles(filesystem.LDAPStorePath) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) } @@ -169,7 +169,7 @@ func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter } if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA) + caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath } diff --git a/api/http/handler/stack.go b/api/http/handler/stack.go index 1f6b23d6e..3ad0ce7ae 100644 --- a/api/http/handler/stack.go +++ b/api/http/handler/stack.go @@ -9,7 +9,7 @@ import ( "github.com/asaskevich/govalidator" "github.com/portainer/portainer" - "github.com/portainer/portainer/file" + "github.com/portainer/portainer/filesystem" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -37,6 +37,14 @@ type StackHandler struct { StackManager portainer.StackManager } +type stackDeploymentConfig struct { + endpoint *portainer.Endpoint + stack *portainer.Stack + prune bool + dockerhub *portainer.DockerHub + registries []portainer.Registry +} + // NewStackHandler returns a new instance of StackHandler. func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler { h := &StackHandler{ @@ -78,6 +86,7 @@ type ( putStackRequest struct { StackFileContent string `valid:"required"` Env []portainer.Pair `valid:""` + Prune bool `valid:"-"` } ) @@ -166,7 +175,7 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, ID: portainer.StackID(stackName + "_" + swarmID), Name: stackName, SwarmID: swarmID, - EntryPoint: file.ComposeFileDefaultName, + EntryPoint: filesystem.ComposeFileDefaultName, Env: req.Env, } @@ -207,7 +216,14 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, return } - err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) + config := stackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: false, + } + err = handler.deployStack(&config) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -264,7 +280,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri } if req.PathInRepository == "" { - req.PathInRepository = file.ComposeFileDefaultName + req.PathInRepository = filesystem.ComposeFileDefaultName } stacks, err := handler.StackService.Stacks() @@ -334,7 +350,14 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri return } - err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) + config := stackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: false, + } + err = handler.deployStack(&config) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -404,7 +427,7 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r ID: portainer.StackID(stackName + "_" + swarmID), Name: stackName, SwarmID: swarmID, - EntryPoint: file.ComposeFileDefaultName, + EntryPoint: filesystem.ComposeFileDefaultName, Env: env, } @@ -445,7 +468,14 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r return } - err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) + config := stackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: false, + } + err = handler.deployStack(&config) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -637,7 +667,14 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque return } - err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries) + config := stackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + dockerhub: dockerhub, + registries: filteredRegistries, + prune: req.Prune, + } + err = handler.deployStack(&config) if err != nil { httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return @@ -732,22 +769,22 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re } } -func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error { +func (handler *StackHandler) deployStack(config *stackDeploymentConfig) error { handler.stackCreationMutex.Lock() - err := handler.StackManager.Login(dockerhub, registries, endpoint) + err := handler.StackManager.Login(config.dockerhub, config.registries, config.endpoint) if err != nil { handler.stackCreationMutex.Unlock() return err } - err = handler.StackManager.Deploy(stack, endpoint) + err = handler.StackManager.Deploy(config.stack, config.prune, config.endpoint) if err != nil { handler.stackCreationMutex.Unlock() return err } - err = handler.StackManager.Logout(endpoint) + err = handler.StackManager.Logout(config.endpoint) if err != nil { handler.stackCreationMutex.Unlock() return err diff --git a/api/http/health_check.go b/api/http/health_check.go new file mode 100644 index 000000000..08f4623f0 --- /dev/null +++ b/api/http/health_check.go @@ -0,0 +1,11 @@ +package http + +import ( + "net/http" +) + +// HealthCheck GETs /api/status +func HealthCheck(addr string) (int, error) { + resp, err := http.Get("http://" + addr + "/api/status") + return resp.StatusCode, err +} diff --git a/api/portainer.go b/api/portainer.go index 92d2afd9c..9aac8cfb2 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -13,10 +13,10 @@ type ( CLIFlags struct { Addr *string Assets *string + CheckHealth *bool Data *string - ExternalEndpoints *string - SyncInterval *string Endpoint *string + ExternalEndpoints *string NoAuth *bool NoAnalytics *bool TLSVerify *bool @@ -26,12 +26,13 @@ type ( SSL *bool SSLCert *string SSLKey *string + SyncInterval *string AdminPassword *string AdminPasswordFile *string // Deprecated fields + Labels *[]Pair Logo *string Templates *string - Labels *[]Pair } // Status represents the application status. @@ -383,14 +384,14 @@ type ( StackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error Logout(endpoint *Endpoint) error - Deploy(stack *Stack, endpoint *Endpoint) error + Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error } ) const ( // APIVersion is the version number of the Portainer API. - APIVersion = "1.15.5" + APIVersion = "1.16.0" // DBVersion is the version number of the Portainer database. DBVersion = 7 // DefaultTemplatesURL represents the default URL for the templates definitions. diff --git a/api/swagger.yaml b/api/swagger.yaml index f2acb8fad..542aeb667 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.5" + version: "1.16.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -77,6 +77,8 @@ tags: description: "Manage Portainer settings" - name: "status" description: "Information about the Portainer instance" +- name: "stacks" + description: "Manage Docker stacks" - name: "users" description: "Manage users" - name: "teams" @@ -436,6 +438,285 @@ paths: schema: $ref: "#/definitions/GenericError" + /endpoints/{endpointId}/stacks: + get: + tags: + - "stacks" + summary: "List stacks" + description: | + List all stacks based on the current user authorizations. + Will return all stacks if using an administrator account otherwise it + will only return the list of stacks the user have access to. + **Access policy**: restricted + operationId: "StackList" + produces: + - "application/json" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackListResponse" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Access denied to resource" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + post: + tags: + - "stacks" + summary: "Deploy a new stack" + description: | + Deploy a new stack into a Docker environment specified via the endpoint identifier. + **Access policy**: restricted + operationId: "StackCreate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "method" + in: "query" + description: "Stack deployment method. Possible values: string or repository." + required: true + type: "string" + - in: "body" + name: "body" + description: "Stack details. Used when" + required: true + schema: + $ref: "#/definitions/StackCreateRequest" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackCreateResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Endpoint not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Endpoint not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /endpoints/{endpointId}/stacks/{id}: + get: + tags: + - "stacks" + summary: "Inspect a stack" + description: | + Retrieve details about a stack. + **Access policy**: restricted + operationId: "StackInspect" + produces: + - "application/json" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/Stack" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + put: + tags: + - "stacks" + summary: "Update a stack" + description: | + Update a stack. + **Access policy**: restricted + operationId: "StackUpdate" + consumes: + - "application/json" + produces: + - "application/json" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + - in: "body" + name: "body" + description: "Stack details" + required: true + schema: + $ref: "#/definitions/StackUpdateRequest" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request data format" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + delete: + tags: + - "stacks" + summary: "Remove a stack" + description: | + Remove a stack. + **Access policy**: restricted + operationId: "StackDelete" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" + /endpoints/{endpointId}/stacks/{id}/stackfile: + get: + tags: + - "stacks" + summary: "Retrieve the content of the Stack file for the specified stack" + description: | + Get Stack file content. + **Access policy**: restricted + operationId: "StackFileInspect" + produces: + - "application/json" + parameters: + - name: "endpointId" + in: "path" + description: "Endpoint identifier" + required: true + type: "integer" + - name: "id" + in: "path" + description: "Stack identifier" + required: true + type: "string" + responses: + 200: + description: "Success" + schema: + $ref: "#/definitions/StackFileInspectResponse" + 400: + description: "Invalid request" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Invalid request" + 403: + description: "Unauthorized" + schema: + $ref: "#/definitions/GenericError" + 404: + description: "Stack not found" + schema: + $ref: "#/definitions/GenericError" + examples: + application/json: + err: "Stack not found" + 500: + description: "Server error" + schema: + $ref: "#/definitions/GenericError" /registries: get: tags: @@ -536,7 +817,7 @@ paths: $ref: "#/definitions/GenericError" examples: application/json: - err: "Endpoint not found" + err: "Registry not found" 500: description: "Server error" schema: @@ -593,13 +874,6 @@ paths: description: "Server error" schema: $ref: "#/definitions/GenericError" - 503: - description: "Endpoint management disabled" - schema: - $ref: "#/definitions/GenericError" - examples: - application/json: - err: "Endpoint management is disabled" delete: tags: - "registries" @@ -1869,7 +2143,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.15.5" + example: "1.16.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -1883,7 +2157,7 @@ definitions: DisplayDonationHeader: type: "boolean" example: true - description: "Whether to display or not the donation message in the header."\ + description: "Whether to display or not the donation message in the header." DisplayExternalContributors: type: "boolean" example: false @@ -2612,3 +2886,104 @@ definitions: type: "string" example: "nginx:latest" description: "The Docker image associated to the template" + StackCreateRequest: + type: "object" + required: + - "Name" + - "SwarmID" + properties: + Name: + type: "string" + example: "myStack" + description: "Name of the stack" + SwarmID: + type: "string" + example: "jpofkc0i9uo9wtx1zesuk649w" + description: "Cluster identifier of the Swarm cluster" + StackFileContent: + type: "string" + example: "version: 3\n services:\n web:\n image:nginx" + description: "Content of the Stack file. Required when using the 'string' deployment method." + GitRepository: + type: "string" + example: "https://github.com/openfaas/faas" + description: "URL of a public Git repository hosting the Stack file. Required when using the 'repository' deployment method." + PathInRepository: + type: "string" + example: "docker-compose.yml" + description: "Path to the Stack file inside the Git repository. Required when using the 'repository' deployment method." + Env: + type: "array" + description: "A list of environment variables used during stack deployment" + items: + $ref: "#/definitions/Stack_Env" + Stack_Env: + properties: + name: + type: "string" + example: "MYSQL_ROOT_PASSWORD" + value: + type: "string" + example: "password" + StackCreateResponse: + type: "object" + properties: + Id: + type: "string" + example: "myStack_jpofkc0i9uo9wtx1zesuk649w" + description: "Id of the stack" + StackListResponse: + type: "array" + items: + $ref: "#/definitions/Stack" + Stack: + type: "object" + properties: + Id: + type: "string" + example: "myStack_jpofkc0i9uo9wtx1zesuk649w" + description: "Stack identifier" + Name: + type: "string" + example: "myStack" + description: "Stack name" + EntryPoint: + type: "string" + example: "docker-compose.yml" + description: "Path to the Stack file" + SwarmID: + type: "string" + example: "jpofkc0i9uo9wtx1zesuk649w" + description: "Cluster identifier of the Swarm cluster where the stack is deployed" + ProjectPath: + type: "string" + example: "/data/compose/myStack_jpofkc0i9uo9wtx1zesuk649w" + description: "Path on disk to the repository hosting the Stack file" + Env: + type: "array" + description: "A list of environment variables used during stack deployment" + items: + $ref: "#/definitions/Stack_Env" + StackUpdateRequest: + type: "object" + properties: + StackFileContent: + type: "string" + example: "version: 3\n services:\n web:\n image:nginx" + description: "New content of the Stack file." + Env: + type: "array" + description: "A list of environment variables used during stack deployment" + items: + $ref: "#/definitions/Stack_Env" + Prune: + type: "boolean" + example: false + description: "Prune services that are no longer referenced" + StackFileInspectResponse: + type: "object" + properties: + StackFileContent: + type: "string" + example: "version: 3\n services:\n web:\n image:nginx" + description: "Content of the Stack file." diff --git a/app/__module.js b/app/__module.js index 9e02e45c8..649d4cea1 100644 --- a/app/__module.js +++ b/app/__module.js @@ -5,6 +5,7 @@ angular.module('portainer', [ 'ngCookies', 'ngSanitize', 'ngFileUpload', + 'ngMessages', 'angularUtils.directives.dirPagination', 'LocalStorageModule', 'angular-jwt', @@ -40,6 +41,7 @@ angular.module('portainer', [ 'endpointAccess', 'endpoints', 'events', + 'extension.storidge', 'image', 'images', 'initAdmin', diff --git a/app/app.js b/app/app.js index 1d798a4ea..37d24cb1e 100644 --- a/app/app.js +++ b/app/app.js @@ -7,7 +7,7 @@ angular.module('portainer') StateManager.initialize() .then(function success(state) { if (state.application.authentication) { - initAuthentication(authManager, Authentication, $rootScope); + initAuthentication(authManager, Authentication, $rootScope, $state); } if (state.application.analytics) { initAnalytics(Analytics, $rootScope); @@ -30,7 +30,7 @@ angular.module('portainer') }]); -function initAuthentication(authManager, Authentication, $rootScope) { +function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); authManager.redirectWhenUnauthenticated(); Authentication.init(); diff --git a/app/components/containerLogs/containerLogsController.js b/app/components/containerLogs/containerLogsController.js index 941dbcc51..6e48bc47e 100644 --- a/app/components/containerLogs/containerLogsController.js +++ b/app/components/containerLogs/containerLogsController.js @@ -1,6 +1,6 @@ angular.module('containerLogs', []) -.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container', -function ($scope, $transition$, $anchorScroll, ContainerLogs, Container) { +.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container', 'Notifications', +function ($scope, $transition$, $anchorScroll, ContainerLogs, Container, Notifications) { $scope.state = {}; $scope.state.displayTimestampsOut = false; $scope.state.displayTimestampsErr = false; diff --git a/app/components/createContainer/createContainerController.js b/app/components/createContainer/createContainerController.js index 17fba00ce..cab587bd4 100644 --- a/app/components/createContainer/createContainerController.js +++ b/app/components/createContainer/createContainerController.js @@ -11,6 +11,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai NetworkContainer: '', Labels: [], ExtraHosts: [], + MacAddress: '', IPv4: '', IPv6: '', AccessControlData: new AccessControlFormData(), @@ -34,6 +35,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai Image: '', Env: [], Cmd: '', + MacAddress: '', ExposedPorts: {}, HostConfig: { RestartPolicy: { @@ -193,6 +195,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai config.Hostname = ''; } config.HostConfig.NetworkMode = networkMode; + config.MacAddress = $scope.formValues.MacAddress; config.NetworkingConfig.EndpointsConfig[networkMode] = { IPAMConfig: { @@ -387,6 +390,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } $scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]; + // Mac Address + $scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress; // ExtraHosts for (var h in $scope.config.HostConfig.ExtraHosts) { if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) { @@ -396,7 +401,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } } - function loadFromContainerEnvrionmentVariables(d) { + function loadFromContainerEnvironmentVariables(d) { var envArr = []; for (var e in $scope.config.Env) { if ({}.hasOwnProperty.call($scope.config.Env, e)) { @@ -478,7 +483,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai loadFromContainerPortBindings(d); loadFromContainerVolumes(d); loadFromContainerNetworkConfig(d); - loadFromContainerEnvrionmentVariables(d); + loadFromContainerEnvironmentVariables(d); loadFromContainerLabels(d); loadFromContainerConsole(d); loadFromContainerDevices(d); diff --git a/app/components/createContainer/createcontainer.html b/app/components/createContainer/createcontainer.html index 8c9098368..7a692a25e 100644 --- a/app/components/createContainer/createcontainer.html +++ b/app/components/createContainer/createcontainer.html @@ -332,6 +332,14 @@ + +
+ +
+ +
+
+
diff --git a/app/components/createService/createServiceController.js b/app/components/createService/createServiceController.js index ab85f14ed..6a74afc70 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', '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) { +.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'PluginService', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService', +function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, PluginService, RegistryService, HttpRequestHelper, NodeService, SettingsService) { $scope.formValues = { Name: '', @@ -20,6 +20,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C Volumes: [], Network: '', ExtraNetworks: [], + HostsEntries: [], Ports: [], Parallelism: 1, PlacementConstraints: [], @@ -39,7 +40,9 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C RestartCondition: 'any', RestartDelay: '5s', RestartMaxAttempts: 0, - RestartWindow: '0s' + RestartWindow: '0s', + LogDriverName: '', + LogDriverOpts: [] }; $scope.state = { @@ -69,6 +72,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C $scope.formValues.ExtraNetworks.splice(index, 1); }; + $scope.addHostsEntry = function() { + $scope.formValues.HostsEntries.push({}); + }; + + $scope.removeHostsEntry = function(index) { + $scope.formValues.HostsEntries.splice(index, 1); + }; + $scope.addVolume = function() { $scope.formValues.Volumes.push({ Source: '', Target: '', ReadOnly: false, Type: 'volume' }); }; @@ -133,6 +144,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C $scope.formValues.ContainerLabels.splice(index, 1); }; + $scope.addLogDriverOpt = function(value) { + $scope.formValues.LogDriverOpts.push({ name: '', value: ''}); + }; + + $scope.removeLogDriverOpt = function(index) { + $scope.formValues.LogDriverOpts.splice(index, 1); + }; + function prepareImageConfig(config, input) { var imageConfig = ImageHelper.createImageConfigForContainer(input.Image, input.Registry.URL); config.TaskTemplate.ContainerSpec.Image = imageConfig.fromImage + ':' + imageConfig.tag; @@ -244,6 +263,22 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C config.Networks = _.uniqWith(networks, _.isEqual); } + function prepareHostsEntries(config, input) { + var hostsEntries = []; + if (input.HostsEntries) { + input.HostsEntries.forEach(function (host_ip) { + if (host_ip.value && host_ip.value.indexOf(':') && host_ip.value.split(':').length === 2) { + var keyVal = host_ip.value.split(':'); + // Hosts file format, IP_address canonical_hostname + hostsEntries.push(keyVal[1] + ' ' + keyVal[0]); + } + }); + if (hostsEntries.length > 0) { + config.TaskTemplate.ContainerSpec.Hosts = hostsEntries; + } + } + } + function prepareUpdateConfig(config, input) { config.UpdateConfig = { Parallelism: input.Parallelism || 0, @@ -330,6 +365,23 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C } } + function prepareLogDriverConfig(config, input) { + var logOpts = {}; + if (input.LogDriverName) { + config.TaskTemplate.LogDriver = { Name: input.LogDriverName }; + if (input.LogDriverName !== 'none') { + input.LogDriverOpts.forEach(function (opt) { + if (opt.name) { + logOpts[opt.name] = opt.value; + } + }); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.TaskTemplate.LogDriver.Options = logOpts; + } + } + } + } + function prepareConfiguration() { var input = $scope.formValues; var config = { @@ -355,6 +407,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C prepareLabelsConfig(config, input); prepareVolumes(config, input); prepareNetworks(config, input); + prepareHostsEntries(config, input); prepareUpdateConfig(config, input); prepareConfigConfig(config, input); prepareSecretConfig(config, input); @@ -362,6 +415,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C prepareResourcesCpuConfig(config, input); prepareResourcesMemoryConfig(config, input); prepareRestartPolicy(config, input); + prepareLogDriverConfig(config, input); return config; } @@ -448,17 +502,17 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, C secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [], nodes: NodeService.nodes(), - settings: SettingsService.publicSettings() + settings: SettingsService.publicSettings(), + availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25) }) .then(function success(data) { $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; - $scope.allowBindMounts = settings.AllowBindMountsForRegularUsers; + $scope.availableConfigs = data.configs; + $scope.availableLoggingDrivers = data.availableLoggingDrivers; + initSlidersMaxValuesBasedOnNodeData(data.nodes); + $scope.allowBindMounts = data.settings.AllowBindMountsForRegularUsers; var userDetails = Authentication.getUserDetails(); $scope.isAdmin = userDetails.role === 1; }) diff --git a/app/components/createService/createservice.html b/app/components/createService/createservice.html index 9a86ee73c..36870fabc 100644 --- a/app/components/createService/createservice.html +++ b/app/components/createService/createservice.html @@ -126,7 +126,7 @@
+ +
+
+ + + add additional entry + +
+ +
+
+
+ value + +
+ +
+
+ +
+ diff --git a/app/components/createVolume/createVolumeController.js b/app/components/createVolume/createVolumeController.js index 491276484..f1e542fe4 100644 --- a/app/components/createVolume/createVolumeController.js +++ b/app/components/createVolume/createVolumeController.js @@ -1,6 +1,6 @@ angular.module('createVolume', []) -.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', -function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) { +.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', 'ExtensionManager', +function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, ExtensionManager) { $scope.formValues = { Driver: 'local', @@ -40,6 +40,12 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi var name = $scope.formValues.Name; var driver = $scope.formValues.Driver; var driverOptions = $scope.formValues.DriverOptions; + var storidgeProfile = $scope.formValues.StoridgeProfile; + + if (driver === 'cio:latest' && storidgeProfile) { + driverOptions.push({ name: 'profile', value: storidgeProfile.Name }); + } + var volumeConfiguration = VolumeService.createVolumeConfiguration(name, driver, driverOptions); var accessControlData = $scope.formValues.AccessControlData; var userDetails = Authentication.getUserDetails(); @@ -82,5 +88,11 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi } } - initView(); + ExtensionManager.init() + .then(function success(data) { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to initialize extensions'); + }); }]); diff --git a/app/components/createVolume/createvolume.html b/app/components/createVolume/createvolume.html index 6610f24fe..e2ad9c580 100644 --- a/app/components/createVolume/createvolume.html +++ b/app/components/createVolume/createvolume.html @@ -62,6 +62,14 @@ + +
+
+ Storidge +
+ +
+ diff --git a/app/components/service/includes/hosts.html b/app/components/service/includes/hosts.html new file mode 100644 index 000000000..7d9ab78c7 --- /dev/null +++ b/app/components/service/includes/hosts.html @@ -0,0 +1,57 @@ +
+ + + + + +

The Hosts file has no extra entries.

+
+ + + + + + + + + + + + + + +
HostnameIP
+
+ +
+
+
+ + + + +
+
+
+ + + +
+
diff --git a/app/components/service/includes/logging.html b/app/components/service/includes/logging.html new file mode 100644 index 000000000..2172ac902 --- /dev/null +++ b/app/components/service/includes/logging.html @@ -0,0 +1,65 @@ +
+ + + + +
+ Driver: + + + add logging driver option + +
+ + + + + + + + + + + + + + + + +
OptionValue
+
+ name + +
+
+
+ value + + + + +
+
No options associated to this logging driver.
+
+ + + +
+
\ No newline at end of file diff --git a/app/components/service/service.html b/app/components/service/service.html index 4715f5323..841fb4b44 100644 --- a/app/components/service/service.html +++ b/app/components/service/service.html @@ -38,7 +38,6 @@ ID {{ service.Id }} - @@ -72,11 +71,17 @@ ng-model="service.Image" ng-change="updateServiceAttribute(service, 'Image')" id="image_name" ng-disabled="isUpdating"> - + -
- Logs -
+ Service logs + + @@ -116,6 +121,7 @@
  • Placement preferences
  • Restart policy
  • Update configuration
  • +
  • Logging
  • Service labels
  • Configs
  • Secrets
  • @@ -152,6 +158,7 @@

    Networks & ports

    +
    @@ -164,6 +171,7 @@
    +
    diff --git a/app/components/service/serviceController.js b/app/components/service/serviceController.js index 45518e02b..e39b4a76f 100644 --- a/app/components/service/serviceController.js +++ b/app/components/service/serviceController.js @@ -1,8 +1,12 @@ angular.module('service', []) -.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'ModalService', -function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, ModalService) { +.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'ModalService', 'PluginService', +function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, ModalService, PluginService) { + + $scope.state = { + updateInProgress: false, + deletionInProgress: false + }; - $scope.state = {}; $scope.tasks = []; $scope.availableImages = []; @@ -168,6 +172,41 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, } }; + $scope.addLogDriverOpt = function addLogDriverOpt(service) { + service.LogDriverOpts.push({ key: '', value: '', originalValue: '' }); + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + }; + $scope.removeLogDriverOpt = function removeLogDriverOpt(service, index) { + var removedElement = service.LogDriverOpts.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + } + }; + $scope.updateLogDriverOpt = function updateLogDriverOpt(service, variable) { + if (variable.value !== variable.originalValue || variable.key !== variable.originalKey) { + updateServiceArray(service, 'LogDriverOpts', service.LogDriverOpts); + } + }; + $scope.updateLogDriverName = function updateLogDriverName(service) { + updateServiceArray(service, 'LogDriverName', service.LogDriverName); + }; + + $scope.addHostsEntry = function (service) { + if (!service.Hosts) { + service.Hosts = []; + } + service.Hosts.push({ hostname: '', ip: '' }); + }; + $scope.removeHostsEntry = function(service, index) { + var removedElement = service.Hosts.splice(index, 1); + if (removedElement !== null) { + updateServiceArray(service, 'Hosts', service.Hosts); + } + }; + $scope.updateHostsEntry = function(service, entry) { + updateServiceArray(service, 'Hosts', service.Hosts); + }; + $scope.cancelChanges = function cancelChanges(service, keys) { if (keys) { // clean out the keys only from the list of modified keys keys.forEach(function(key) { @@ -203,6 +242,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, 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) : []; + config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : []; if (service.Mode === 'replicated') { config.Mode.Replicated.Replicas = service.Replicas; @@ -244,6 +284,17 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, Window: ServiceHelper.translateHumanDurationToNanos(service.RestartWindow) || 0 }; + config.TaskTemplate.LogDriver = null; + if (service.LogDriverName) { + config.TaskTemplate.LogDriver = { Name: service.LogDriverName }; + if (service.LogDriverName !== 'none') { + var logOpts = ServiceHelper.translateKeyValueToLogDriverOpts(service.LogDriverOpts); + if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) { + config.TaskTemplate.LogDriver.Options = logOpts; + } + } + } + if (service.Ports) { service.Ports.forEach(function (binding) { if (binding.PublishedPort === null || binding.PublishedPort === '') { @@ -253,7 +304,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, } config.EndpointSpec = { - Mode: config.EndpointSpec.Mode || 'vip', + Mode: (config.EndpointSpec && config.EndpointSpec.Mode) || 'vip', Ports: service.Ports }; @@ -281,6 +332,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }; function removeService() { + $scope.state.deletionInProgress = true; ServiceService.remove($scope.service) .then(function success(data) { Notifications.success('Service successfully deleted'); @@ -288,6 +340,39 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to remove service'); + }) + .finally(function final() { + $scope.state.deletionInProgress = false; + }); + } + + $scope.forceUpdateService = function(service) { + ModalService.confirmServiceForceUpdate( + 'Do you want to force update this service? All the tasks associated to the selected service(s) will be recreated.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + forceUpdateService(service); + } + ); + }; + + function forceUpdateService(service) { + var config = ServiceHelper.serviceToConfig(service.Model); + // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random + // value or an increment of the counter value to force an update. + config.TaskTemplate.ForceUpdate++; + $scope.state.updateInProgress = true; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully updated', service.Name); + $scope.cancelChanges({}); + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to force update service', service.Name); + }) + .finally(function final() { + $scope.state.updateInProgress = false; }); } @@ -295,11 +380,13 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, 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.LogDriverOpts = ServiceHelper.translateLogDriverOptsToKeyValue(service.LogDriverOpts); service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels); service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels); service.ServiceMounts = angular.copy(service.Mounts); service.ServiceConstraints = ServiceHelper.translateConstraintsToKeyValue(service.Constraints); service.ServicePreferences = ServiceHelper.translatePreferencesToKeyValue(service.Preferences); + service.Hosts = ServiceHelper.translateHostsEntriesToHostnameIP(service.Hosts); } function transformResources(service) { @@ -317,6 +404,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, function initView() { var apiVersion = $scope.applicationState.endpoint.apiVersion; + ServiceService.service($transition$.params().id) .then(function success(data) { var service = data; @@ -336,7 +424,8 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, nodes: NodeService.nodes(), secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [], - availableImages: ImageService.images() + availableImages: ImageService.images(), + availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25) }); }) .then(function success(data) { @@ -345,6 +434,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, $scope.configs = data.configs; $scope.secrets = data.secrets; $scope.availableImages = ImageService.getUniqueTagListFromImages(data.availableImages); + $scope.availableLoggingDrivers = data.availableLoggingDrivers; // Set max cpu value var maxCpus = 0; diff --git a/app/components/services/services.html b/app/components/services/services.html index 3c23ac6cd..48eebed2e 100644 --- a/app/components/services/services.html +++ b/app/components/services/services.html @@ -16,7 +16,9 @@ show-ownership-column="applicationState.application.authentication" remove-action="removeAction" scale-action="scaleAction" + force-update-action="forceUpdateAction" swarm-manager-ip="swarmManagerIP" + show-force-update-button="applicationState.endpoint.apiVersion >= 1.25" > diff --git a/app/components/services/servicesController.js b/app/components/services/servicesController.js index e2223da48..8937fa392 100644 --- a/app/components/services/servicesController.js +++ b/app/components/services/servicesController.js @@ -17,6 +17,39 @@ function ($q, $scope, $state, Service, ServiceService, ServiceHelper, Notificati }); }; + $scope.forceUpdateAction = function(selectedItems) { + ModalService.confirmServiceForceUpdate( + 'Do you want to force update of selected service(s)? All the tasks associated to the selected service(s) will be recreated.', + function onConfirm(confirmed) { + if(!confirmed) { return; } + forceUpdateServices(selectedItems); + } + ); + }; + + function forceUpdateServices(services) { + var actionCount = services.length; + angular.forEach(services, function (service) { + var config = ServiceHelper.serviceToConfig(service.Model); + // As explained in https://github.com/docker/swarmkit/issues/2364 ForceUpdate can accept a random + // value or an increment of the counter value to force an update. + config.TaskTemplate.ForceUpdate++; + ServiceService.update(service, config) + .then(function success(data) { + Notifications.success('Service successfully updated', service.Name); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to force update service', service.Name); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + $scope.removeAction = function(selectedItems) { ModalService.confirmDeletion( 'Do you want to remove the selected service(s)? All the containers associated to the selected service(s) will be removed too.', diff --git a/app/components/sidebar/sidebar.html b/app/components/sidebar/sidebar.html index 7cfde6625..51ba33df1 100644 --- a/app/components/sidebar/sidebar.html +++ b/app/components/sidebar/sidebar.html @@ -57,6 +57,18 @@ + + diff --git a/app/components/sidebar/sidebarController.js b/app/components/sidebar/sidebarController.js index e60a2f20b..5b7089450 100644 --- a/app/components/sidebar/sidebarController.js +++ b/app/components/sidebar/sidebarController.js @@ -1,6 +1,6 @@ angular.module('sidebar', []) -.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', -function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService) { +.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager', +function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) { $scope.uiVersion = StateManager.getState().application.version; $scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors; @@ -12,8 +12,10 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP var activeEndpointPublicURL = EndpointProvider.endpointPublicURL(); EndpointProvider.setEndpointID(endpoint.Id); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + StateManager.updateEndpointState(true) .then(function success() { + ExtensionManager.reset(); $state.go('dashboard'); }) .catch(function error(err) { diff --git a/app/components/stack/stack.html b/app/components/stack/stack.html index 970f8c294..37f3785ec 100644 --- a/app/components/stack/stack.html +++ b/app/components/stack/stack.html @@ -90,6 +90,22 @@ + +
    + Options +
    +
    +
    + + +
    +
    +
    Actions
    diff --git a/app/components/stack/stackController.js b/app/components/stack/stackController.js index 5484ba82d..91137d33e 100644 --- a/app/components/stack/stackController.js +++ b/app/components/stack/stackController.js @@ -1,20 +1,25 @@ angular.module('stack', []) -.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper', 'EndpointProvider', -function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper, EndpointProvider) { +.controller('StackController', ['$q', '$scope', '$state', '$transition$', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper', 'EndpointProvider', +function ($q, $scope, $state, $transition$, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper, EndpointProvider) { $scope.state = { actionInProgress: false, publicURL: EndpointProvider.endpointPublicURL() }; + $scope.formValues = { + Prune: false + }; + $scope.deployStack = function () { // 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); + var prune = $scope.formValues.Prune; $scope.state.actionInProgress = true; - StackService.updateStack($scope.stack.Id, stackFile, env) + StackService.updateStack($scope.stack.Id, stackFile, env, prune) .then(function success(data) { Notifications.success('Stack successfully deployed'); $state.reload(); @@ -36,7 +41,8 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService }; function initView() { - var stackId = $stateParams.id; + var stackId = $transition$.params().id; + var apiVersion = $scope.applicationState.endpoint.apiVersion; StackService.stack(stackId) .then(function success(data) { diff --git a/app/components/swarmVisualizer/swarmVisualizer.html b/app/components/swarmVisualizer/swarmVisualizer.html index 098a854ee..d8d16a72c 100644 --- a/app/components/swarmVisualizer/swarmVisualizer.html +++ b/app/components/swarmVisualizer/swarmVisualizer.html @@ -50,6 +50,25 @@ +
    +
    + Refresh +
    +
    + +
    + +
    +
    +
    @@ -75,6 +94,7 @@
    {{ node.Role }}
    CPU: {{ node.CPUs / 1000000000 }}
    Memory: {{ node.Memory|humansize: 2 }}
    +
    {{ node.Status }}
    diff --git a/app/components/swarmVisualizer/swarmVisualizerController.js b/app/components/swarmVisualizer/swarmVisualizerController.js index c46ec3d87..f4b4f7706 100644 --- a/app/components/swarmVisualizer/swarmVisualizerController.js +++ b/app/components/swarmVisualizer/swarmVisualizerController.js @@ -1,12 +1,57 @@ angular.module('swarmVisualizer', []) -.controller('SwarmVisualizerController', ['$q', '$scope', '$document', 'NodeService', 'ServiceService', 'TaskService', 'Notifications', -function ($q, $scope, $document, NodeService, ServiceService, TaskService, Notifications) { +.controller('SwarmVisualizerController', ['$q', '$scope', '$document', '$interval', 'NodeService', 'ServiceService', 'TaskService', 'Notifications', +function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskService, Notifications) { $scope.state = { ShowInformationPanel: true, - DisplayOnlyRunningTasks: false + DisplayOnlyRunningTasks: false, + refreshRate: '5' }; + $scope.$on('$destroy', function() { + stopRepeater(); + }); + + $scope.changeUpdateRepeater = function() { + stopRepeater(); + setUpdateRepeater(); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(1500); + }; + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + repeater = null; + } + } + + function setUpdateRepeater() { + var refreshRate = $scope.state.refreshRate; + $scope.repeater = $interval(function() { + $q.all({ + nodes: NodeService.nodes(), + services: ServiceService.services(), + tasks: TaskService.tasks() + }) + .then(function success(data) { + var nodes = data.nodes; + $scope.nodes = nodes; + var services = data.services; + $scope.services = services; + var tasks = data.tasks; + $scope.tasks = tasks; + prepareVisualizerData(nodes, services, tasks); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve cluster information'); + }); + }, refreshRate * 1000); + } + + function assignServiceName(services, tasks) { for (var i = 0; i < services.length; i++) { var service = services[i]; @@ -60,6 +105,7 @@ function ($q, $scope, $document, NodeService, ServiceService, TaskService, Notif var tasks = data.tasks; $scope.tasks = tasks; prepareVisualizerData(nodes, services, tasks); + setUpdateRepeater(); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to initialize cluster visualizer'); diff --git a/app/components/templates/templates.html b/app/components/templates/templates.html index 92664fad0..6084d8a3d 100644 --- a/app/components/templates/templates.html +++ b/app/components/templates/templates.html @@ -288,6 +288,35 @@
    + +
    +
    + + + add label + +
    + +
    +
    +
    +
    + name + +
    +
    + value + +
    + +
    +
    +
    + +
    + diff --git a/app/components/templates/templatesController.js b/app/components/templates/templatesController.js index beff4d48f..2901a743f 100644 --- a/app/components/templates/templatesController.js +++ b/app/components/templates/templatesController.js @@ -45,6 +45,14 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer $scope.state.selectedTemplate.Hosts.splice(index, 1); }; + $scope.addLabel = function () { + $scope.state.selectedTemplate.Labels.push({ name: '', value: ''}); + }; + + $scope.removeLabel = function(index) { + $scope.state.selectedTemplate.Labels.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; diff --git a/app/components/users/users.html b/app/components/users/users.html index 9f6535c4d..ad69ff9b7 100644 --- a/app/components/users/users.html +++ b/app/components/users/users.html @@ -118,6 +118,7 @@ title="Users" title-icon="fa-user" dataset="users" table-key="users" order-by="Username" show-text-filter="true" + authentication-method="AuthenticationMethod" remove-action="removeAction" > diff --git a/app/components/volumes/volumesController.js b/app/components/volumes/volumesController.js index 0e21b48b5..1610800a7 100644 --- a/app/components/volumes/volumesController.js +++ b/app/components/volumes/volumesController.js @@ -40,5 +40,6 @@ function ($q, $scope, $state, VolumeService, Notifications) { Notifications.error('Failure', err, 'Unable to retrieve volumes'); }); } + initView(); }]); diff --git a/app/constants.js b/app/constants.js index d62fd0a96..4062f9dd3 100644 --- a/app/constants.js +++ b/app/constants.js @@ -11,4 +11,5 @@ angular.module('portainer') .constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships') .constant('API_ENDPOINT_TEMPLATES', 'api/templates') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') -.constant('PAGINATION_MAX_ITEMS', 10); +.constant('PAGINATION_MAX_ITEMS', 10) +.constant('APPLICATION_CACHE_VALIDITY', 3600); diff --git a/app/directives/autofocus.js b/app/directives/autofocus.js index 0b9029c33..7843e8139 100644 --- a/app/directives/autofocus.js +++ b/app/directives/autofocus.js @@ -1,11 +1,10 @@ -angular -.module('portainer') +angular.module('portainer') .directive('autoFocus', ['$timeout', function porAutoFocus($timeout) { var directive = { restrict: 'A', - link: function($scope, $element) { + link: function(scope, element) { $timeout(function() { - $element[0].focus(); + element[0].focus(); }); } }; diff --git a/app/directives/onEnterKey.js b/app/directives/onEnterKey.js new file mode 100644 index 000000000..1d13e8d22 --- /dev/null +++ b/app/directives/onEnterKey.js @@ -0,0 +1,18 @@ +angular.module('portainer') +.directive('onEnterKey', [function porOnEnterKey() { + var directive = { + restrict: 'A', + link: function(scope, element, attrs) { + element.bind('keydown keypress', function (e) { + if ( e.which === 13 ) { + e.preventDefault(); + scope.$apply(function () { + scope.$eval(attrs.onEnterKey); + }); + } + }); + } + }; + + return directive; +}]); diff --git a/app/directives/ui/datatables/containers-datatable/containersDatatable.html b/app/directives/ui/datatables/containers-datatable/containersDatatable.html index a590dd4e8..354a0b2b5 100644 --- a/app/directives/ui/datatables/containers-datatable/containersDatatable.html +++ b/app/directives/ui/datatables/containers-datatable/containersDatatable.html @@ -202,7 +202,7 @@ {{ item.StackName ? item.StackName : '-' }} - {{ item.Image | hideshasum }} + {{ item.Image | trimshasum }} {{ item.IP ? item.IP : '-' }} {{ item.hostIP }} diff --git a/app/directives/ui/datatables/containers-datatable/containersDatatableController.js b/app/directives/ui/datatables/containers-datatable/containersDatatableController.js index ccd65507a..997b5e727 100644 --- a/app/directives/ui/datatables/containers-datatable/containersDatatableController.js +++ b/app/directives/ui/datatables/containers-datatable/containersDatatableController.js @@ -70,16 +70,22 @@ function (PaginationService, DatatableService) { for (var i = 0; i < this.dataset.length; i++) { var item = this.dataset[i]; - if (item.Checked && item.Status === 'paused') { - this.state.noPausedItemsSelected = false; - } else if (item.Checked && (item.Status === 'stopped' || item.Status === 'created')) { - this.state.noStoppedItemsSelected = false; - } else if (item.Checked && item.Status === 'running') { - this.state.noRunningItemsSelected = false; + if (item.Checked) { + this.updateSelectionStateBasedOnItemStatus(item); } } }; + this.updateSelectionStateBasedOnItemStatus = function(item) { + if (item.Status === 'paused') { + this.state.noPausedItemsSelected = false; + } else if (['stopped', 'created'].indexOf(item.Status) !== -1) { + this.state.noStoppedItemsSelected = false; + } else if (['running', 'healthy', 'unhealthy', 'starting'].indexOf(item.Status) !== -1) { + this.state.noRunningItemsSelected = false; + } + }; + this.changePaginationLimit = function() { PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); }; @@ -138,7 +144,20 @@ function (PaginationService, DatatableService) { } availableStateFilters.push({ label: item.Status, display: true }); } - this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + this.filters.state.values = _.uniqBy(availableStateFilters, 'label'); + }; + + this.updateStoredFilters = function(storedFilters) { + var datasetFilters = this.filters.state.values; + + for (var i = 0; i < datasetFilters.length; i++) { + var filter = datasetFilters[i]; + existingFilter = _.find(storedFilters, ['label', filter.label]); + if (existingFilter && !existingFilter.display) { + filter.display = existingFilter.display; + this.filters.state.enabled = true; + } + } }; this.$onInit = function() { @@ -153,7 +172,7 @@ function (PaginationService, DatatableService) { var storedFilters = DatatableService.getDataTableFilters(this.tableKey); if (storedFilters !== null) { - this.filters = storedFilters; + this.updateStoredFilters(storedFilters.state.values); } this.filters.state.open = false; diff --git a/app/directives/ui/datatables/networks-datatable/networksDatatable.html b/app/directives/ui/datatables/networks-datatable/networksDatatable.html index 8dcf9846e..db929fabe 100644 --- a/app/directives/ui/datatables/networks-datatable/networksDatatable.html +++ b/app/directives/ui/datatables/networks-datatable/networksDatatable.html @@ -97,7 +97,7 @@ - {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} {{ item.StackName ? item.StackName : '-' }} {{ item.Scope }} diff --git a/app/directives/ui/datatables/services-datatable/servicesDatatable.html b/app/directives/ui/datatables/services-datatable/servicesDatatable.html index 60afade12..cf94c0f6a 100644 --- a/app/directives/ui/datatables/services-datatable/servicesDatatable.html +++ b/app/directives/ui/datatables/services-datatable/servicesDatatable.html @@ -12,10 +12,16 @@
    - +
    + + +
    @@ -102,7 +108,7 @@ - + diff --git a/app/directives/ui/datatables/services-datatable/servicesDatatable.js b/app/directives/ui/datatables/services-datatable/servicesDatatable.js index 14ae8954c..9d33fea66 100644 --- a/app/directives/ui/datatables/services-datatable/servicesDatatable.js +++ b/app/directives/ui/datatables/services-datatable/servicesDatatable.js @@ -12,6 +12,8 @@ angular.module('ui').component('servicesDatatable', { showOwnershipColumn: '<', removeAction: '<', scaleAction: '<', - swarmManagerIp: '<' + swarmManagerIp: '<', + forceUpdateAction: '<', + showForceUpdateButton: '<' } }); diff --git a/app/directives/ui/datatables/stack-services-datatable/stackServicesDatatable.js b/app/directives/ui/datatables/stack-services-datatable/stackServicesDatatable.js index d0cabf1b1..219df8530 100644 --- a/app/directives/ui/datatables/stack-services-datatable/stackServicesDatatable.js +++ b/app/directives/ui/datatables/stack-services-datatable/stackServicesDatatable.js @@ -9,7 +9,7 @@ angular.module('ui').component('stackServicesDatatable', { orderBy: '@', reverseOrder: '<', nodes: '<', - publicURL: '<', + publicUrl: '<', showTextFilter: '<' } }); diff --git a/app/directives/ui/datatables/users-datatable/usersDatatable.html b/app/directives/ui/datatables/users-datatable/usersDatatable.html index 833c1d3e9..9e5d5c40f 100644 --- a/app/directives/ui/datatables/users-datatable/usersDatatable.html +++ b/app/directives/ui/datatables/users-datatable/usersDatatable.html @@ -70,8 +70,8 @@ - Internal - LDAP + Internal + LDAP diff --git a/app/directives/ui/datatables/users-datatable/usersDatatable.js b/app/directives/ui/datatables/users-datatable/usersDatatable.js index f59ca6ae9..565cf3da5 100644 --- a/app/directives/ui/datatables/users-datatable/usersDatatable.js +++ b/app/directives/ui/datatables/users-datatable/usersDatatable.js @@ -9,6 +9,7 @@ angular.module('ui').component('usersDatatable', { orderBy: '@', reverseOrder: '<', showTextFilter: '<', - removeAction: '<' + removeAction: '<', + authenticationMethod: '<' } }); diff --git a/app/extensions/storidge/__module.js b/app/extensions/storidge/__module.js new file mode 100644 index 000000000..db7ae432c --- /dev/null +++ b/app/extensions/storidge/__module.js @@ -0,0 +1,103 @@ +angular.module('extension.storidge', []) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var storidge = { + name: 'storidge', + abstract: true, + url: '/storidge', + views: { + 'content@': { + template: '
    ' + }, + 'sidebar@': { + template: '
    ' + } + } + }; + + var profiles = { + name: 'storidge.profiles', + url: '/profiles', + views: { + 'content@': { + templateUrl: 'app/extensions/storidge/views/profiles/profiles.html', + controller: 'StoridgeProfilesController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }; + + var profileCreation = { + name: 'storidge.profiles.create', + url: '/create', + params: { + profileName: '' + }, + views: { + 'content@': { + templateUrl: 'app/extensions/storidge/views/profiles/create/createProfile.html', + controller: 'CreateProfileController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }; + + var profileEdition = { + name: 'storidge.profiles.edit', + url: '/edit/:id', + views: { + 'content@': { + templateUrl: 'app/extensions/storidge/views/profiles/edit/editProfile.html', + controller: 'EditProfileController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }; + + var cluster = { + name: 'storidge.cluster', + url: '/cluster', + views: { + 'content@': { + templateUrl: 'app/extensions/storidge/views/cluster/cluster.html', + controller: 'StoridgeClusterController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }; + + var monitor = { + name: 'storidge.monitor', + url: '/events', + views: { + 'content@': { + templateUrl: 'app/extensions/storidge/views/monitor/monitor.html', + controller: 'StoridgeMonitorController' + }, + 'sidebar@': { + templateUrl: 'app/components/sidebar/sidebar.html', + controller: 'SidebarController' + } + } + }; + + $stateRegistryProvider.register(storidge); + $stateRegistryProvider.register(profiles); + $stateRegistryProvider.register(profileCreation); + $stateRegistryProvider.register(profileEdition); + $stateRegistryProvider.register(cluster); + $stateRegistryProvider.register(monitor); +}]); diff --git a/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html new file mode 100644 index 000000000..0bf7bfa50 --- /dev/null +++ b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html @@ -0,0 +1,89 @@ +
    + + +
    +
    + {{ $ctrl.title }} +
    +
    + + Search + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + Date + + + + + + Category + + + + + + Module + + + + + + Content + + + +
    {{ item.Time }}{{ item.Category }}{{ item.Module }}{{ item.Content }}
    Loading...
    No events available.
    +
    + +
    +
    +
    diff --git a/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.js b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.js new file mode 100644 index 000000000..9cc269f33 --- /dev/null +++ b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.js @@ -0,0 +1,13 @@ +angular.module('extension.storidge').component('storidgeClusterEventsDatatable', { + templateUrl: 'app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<' + } +}); diff --git a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html new file mode 100644 index 000000000..63a697d85 --- /dev/null +++ b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -0,0 +1,92 @@ +
    + + +
    +
    + {{ $ctrl.title }} +
    +
    + + Search + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + Name + + + + + + IP Address + + + + + + Role + + + + + + Status + + + +
    {{ item.Name }}{{ item.IP }}{{ item.Role }} + + {{ item.Status }} +
    Loading...
    No nodes available.
    +
    + +
    +
    +
    diff --git a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.js b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.js new file mode 100644 index 000000000..15c88c5e5 --- /dev/null +++ b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.js @@ -0,0 +1,13 @@ +angular.module('extension.storidge').component('storidgeNodesDatatable', { + templateUrl: 'app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<' + } +}); diff --git a/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.html b/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.html new file mode 100644 index 000000000..6629e114c --- /dev/null +++ b/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.html @@ -0,0 +1,8 @@ +
    + +
    + +
    +
    diff --git a/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.js b/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.js new file mode 100644 index 000000000..46d38607b --- /dev/null +++ b/app/extensions/storidge/components/profileSelector/storidgeProfileSelector.js @@ -0,0 +1,7 @@ +angular.module('extension.storidge').component('storidgeProfileSelector', { + templateUrl: 'app/extensions/storidge/components/profileSelector/storidgeProfileSelector.html', + controller: 'StoridgeProfileSelectorController', + bindings: { + 'storidgeProfile': '=' + } +}); diff --git a/app/extensions/storidge/components/profileSelector/storidgeProfileSelectorController.js b/app/extensions/storidge/components/profileSelector/storidgeProfileSelectorController.js new file mode 100644 index 000000000..b75b7824a --- /dev/null +++ b/app/extensions/storidge/components/profileSelector/storidgeProfileSelectorController.js @@ -0,0 +1,17 @@ +angular.module('extension.storidge') +.controller('StoridgeProfileSelectorController', ['StoridgeProfileService', 'Notifications', +function (StoridgeProfileService, Notifications) { + var ctrl = this; + + function initComponent() { + StoridgeProfileService.profiles() + .then(function success(data) { + ctrl.profiles = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve Storidge profiles'); + }); + } + + initComponent(); +}]); diff --git a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html new file mode 100644 index 000000000..353d20344 --- /dev/null +++ b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -0,0 +1,84 @@ +
    + + +
    +
    + {{ $ctrl.title }} +
    +
    + + Search + +
    +
    +
    + +
    + +
    + + + + + + + + + + + + + + + + + +
    + + + + + + Name + + + +
    + + + + + {{ item.Name }} +
    Loading...
    No profile available.
    +
    + +
    +
    +
    diff --git a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.js b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.js new file mode 100644 index 000000000..a593e497f --- /dev/null +++ b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.js @@ -0,0 +1,14 @@ +angular.module('extension.storidge').component('storidgeProfilesDatatable', { + templateUrl: 'app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + removeAction: '<' + } +}); diff --git a/app/extensions/storidge/models/events.js b/app/extensions/storidge/models/events.js new file mode 100644 index 000000000..3fab0d639 --- /dev/null +++ b/app/extensions/storidge/models/events.js @@ -0,0 +1,6 @@ +function StoridgeEventModel(data) { + this.Time = data.time; + this.Category = data.category; + this.Module = data.module; + this.Content = data.content; +} diff --git a/app/extensions/storidge/models/info.js b/app/extensions/storidge/models/info.js new file mode 100644 index 000000000..2db3299ca --- /dev/null +++ b/app/extensions/storidge/models/info.js @@ -0,0 +1,17 @@ +function StoridgeInfoModel(data) { + this.Domain = data.domain; + this.Nodes = data.nodes; + this.Status = data.status; + this.ProvisionedBandwidth = data.provisionedBandwidth; + this.UsedBandwidth = data.usedBandwidth; + this.FreeBandwidth = data.freeBandwidth; + this.TotalBandwidth = data.totalBandwidth; + this.ProvisionedIOPS = data.provisionedIOPS; + this.UsedIOPS = data.usedIOPS; + this.FreeIOPS = data.freeIOPS; + this.TotalIOPS = data.totalIOPS; + this.ProvisionedCapacity = data.provisionedCapacity; + this.UsedCapacity = data.usedCapacity; + this.FreeCapacity = data.freeCapacity; + this.TotalCapacity = data.totalCapacity; +} diff --git a/app/extensions/storidge/models/node.js b/app/extensions/storidge/models/node.js new file mode 100644 index 000000000..e7e46e054 --- /dev/null +++ b/app/extensions/storidge/models/node.js @@ -0,0 +1,6 @@ +function StoridgeNodeModel(name, data) { + this.Name = name; + this.IP = data.ip; + this.Role = data.role; + this.Status = data.status; +} diff --git a/app/extensions/storidge/models/profile.js b/app/extensions/storidge/models/profile.js new file mode 100644 index 000000000..6effb98cf --- /dev/null +++ b/app/extensions/storidge/models/profile.js @@ -0,0 +1,57 @@ +function StoridgeProfileDefaultModel() { + this.Directory = '/cio/'; + this.Capacity = 20; + this.Redundancy = 2; + this.Provisioning = 'thin'; + this.Type = 'ssd'; + this.MinIOPS = 100; + this.MaxIOPS = 2000; + this.MinBandwidth = 1; + this.MaxBandwidth = 100; +} + +function StoridgeProfileListModel(data) { + this.Name = data; + this.Checked = false; +} + +function StoridgeProfileModel(name, data) { + this.Name = name; + this.Directory = data.directory; + this.Capacity = data.capacity; + this.Provisioning = data.provision; + this.Type = data.type; + this.Redundancy = data.level; + + if (data.iops) { + this.MinIOPS = data.iops.min; + this.MaxIOPS = data.iops.max; + } + + if (data.bandwidth) { + this.MinBandwidth = data.bandwidth.min; + this.MaxBandwidth = data.bandwidth.max; + } +} + +function StoridgeCreateProfileRequest(model) { + this.name = model.Name; + this.capacity = model.Capacity; + this.directory = model.Directory; + this.provision = model.Provisioning; + this.type = model.Type; + this.level = model.Redundancy; + if (model.MinIOPS && model.MaxIOPS) { + this.iops = { + min: model.MinIOPS, + max: model.MaxIOPS + }; + } + + if (model.MinBandwidth && model.MaxBandwidth) { + this.bandwidth = { + min: model.MinBandwidth, + max: model.MaxBandwidth + }; + } +} diff --git a/app/extensions/storidge/rest/cluster.js b/app/extensions/storidge/rest/cluster.js new file mode 100644 index 000000000..98b4fc429 --- /dev/null +++ b/app/extensions/storidge/rest/cluster.js @@ -0,0 +1,44 @@ +angular.module('extension.storidge') +.factory('StoridgeCluster', ['$http', 'StoridgeManager', function StoridgeClusterFactory($http, StoridgeManager) { + 'use strict'; + + var service = {}; + + service.queryEvents = function() { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/events', + skipAuthorization: true, + timeout: 4500, + ignoreLoadingBar: true + }); + }; + + service.queryVersion = function() { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/version', + skipAuthorization: true + }); + }; + + service.queryInfo = function() { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/info', + skipAuthorization: true, + timeout: 4500, + ignoreLoadingBar: true + }); + }; + + service.reboot = function() { + return $http({ + method: 'POST', + url: StoridgeManager.StoridgeAPIURL() + '/cluster/reboot', + skipAuthorization: true + }); + }; + + return service; +}]); diff --git a/app/extensions/storidge/rest/node.js b/app/extensions/storidge/rest/node.js new file mode 100644 index 000000000..f69d66da3 --- /dev/null +++ b/app/extensions/storidge/rest/node.js @@ -0,0 +1,24 @@ +angular.module('extension.storidge') +.factory('StoridgeNodes', ['$http', 'StoridgeManager', function StoridgeNodesFactory($http, StoridgeManager) { + 'use strict'; + + var service = {}; + + service.query = function() { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/nodes', + skipAuthorization: true + }); + }; + + service.inspect = function(id) { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/nodes/' + id, + skipAuthorization: true + }); + }; + + return service; +}]); diff --git a/app/extensions/storidge/rest/profile.js b/app/extensions/storidge/rest/profile.js new file mode 100644 index 000000000..f08e10122 --- /dev/null +++ b/app/extensions/storidge/rest/profile.js @@ -0,0 +1,52 @@ +angular.module('extension.storidge') +.factory('StoridgeProfiles', ['$http', 'StoridgeManager', function StoridgeProfilesFactory($http, StoridgeManager) { + 'use strict'; + + var service = {}; + + service.create = function(payload) { + return $http({ + method: 'POST', + url: StoridgeManager.StoridgeAPIURL() + '/profiles', + data: payload, + headers: { 'Content-type': 'application/json' }, + skipAuthorization: true + }); + }; + + service.update = function(id, payload) { + return $http({ + method: 'PUT', + url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id, + data: payload, + headers: { 'Content-type': 'application/json' }, + skipAuthorization: true + }); + }; + + service.query = function() { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/profiles', + skipAuthorization: true + }); + }; + + service.inspect = function(id) { + return $http({ + method: 'GET', + url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id, + skipAuthorization: true + }); + }; + + service.delete = function(id) { + return $http({ + method: 'DELETE', + url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id, + skipAuthorization: true + }); + }; + + return service; +}]); diff --git a/app/extensions/storidge/services/chartService.js b/app/extensions/storidge/services/chartService.js new file mode 100644 index 000000000..de99a9c97 --- /dev/null +++ b/app/extensions/storidge/services/chartService.js @@ -0,0 +1,189 @@ +angular.module('extension.storidge') +.factory('StoridgeChartService', [function StoridgeChartService() { + 'use strict'; + + // Max. number of items to display on a chart + var CHART_LIMIT = 600; + + var service = {}; + + service.CreateCapacityChart = function(context) { + return new Chart(context, { + type: 'doughnut', + data: { + datasets: [ + { + data: [], + backgroundColor: [ + 'rgba(171, 213, 255, 0.7)', + 'rgba(229, 57, 53, 0.7)' + ] + } + ], + labels: [] + }, + options: { + tooltips: { + callbacks: { + label: function(tooltipItem, data) { + var dataset = data.datasets[tooltipItem.datasetIndex]; + var label = data.labels[tooltipItem.index]; + var value = dataset.data[tooltipItem.index]; + return label + ': ' + filesize(value, {base: 10, round: 1}); + } + } + }, + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + hover: { + animationDuration: 0 + } + } + }); + }; + + service.CreateIOPSChart = function(context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'IOPS', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: { + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: 'nearest' + }, + hover: { + animationDuration: 0 + }, + scales: { + yAxes: [ + { + ticks: { + beginAtZero: true + } + } + ] + } + } + }); + }; + + service.CreateBandwidthChart = function(context) { + return new Chart(context, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Bandwidth', + data: [], + fill: true, + backgroundColor: 'rgba(151,187,205,0.4)', + borderColor: 'rgba(151,187,205,0.6)', + pointBackgroundColor: 'rgba(151,187,205,1)', + pointBorderColor: 'rgba(151,187,205,1)', + pointRadius: 2, + borderWidth: 2 + } + ] + }, + options: { + animation: { + duration: 0 + }, + responsiveAnimationDuration: 0, + responsive: true, + tooltips: { + mode: 'index', + intersect: false, + position: 'nearest', + callbacks: { + label: function(tooltipItem, data) { + var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; + return bytePerSecBasedTooltipLabel(datasetLabel, tooltipItem.yLabel); + } + } + }, + hover: { + animationDuration: 0 + }, + scales: { + yAxes: [ + { + ticks: { + beginAtZero: true, + callback: bytePerSecBasedAxisLabel + } + } + ] + } + } + }); + }; + + service.UpdateChart = function(label, value, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + + if (chart.data.datasets[0].data.length > CHART_LIMIT) { + chart.data.labels.pop(); + chart.data.datasets[0].data.pop(); + } + + chart.update(0); + }; + + service.UpdatePieChart = function(label, value, chart) { + var idx = chart.data.labels.indexOf(label); + if (idx > -1) { + chart.data.datasets[0].data[idx] = value; + } else { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(value); + } + + chart.update(0); + }; + + function bytePerSecBasedTooltipLabel(label, value) { + var processedValue = 0; + if (value > 5) { + processedValue = filesize(value, {base: 10, round: 1}); + } else { + processedValue = value.toFixed(1) + 'B'; + } + return label + ': ' + processedValue + '/s'; + } + + function bytePerSecBasedAxisLabel(value, index, values) { + if (value > 5) { + return filesize(value, {base: 10, round: 1}); + } + return value.toFixed(1) + 'B/s'; + } + + return service; +}]); diff --git a/app/extensions/storidge/services/clusterService.js b/app/extensions/storidge/services/clusterService.js new file mode 100644 index 000000000..41cc9a34c --- /dev/null +++ b/app/extensions/storidge/services/clusterService.js @@ -0,0 +1,58 @@ +angular.module('extension.storidge') +.factory('StoridgeClusterService', ['$q', 'StoridgeCluster', function StoridgeClusterServiceFactory($q, StoridgeCluster) { + 'use strict'; + var service = {}; + + service.reboot = function() { + return StoridgeCluster.reboot(); + }; + + service.info = function() { + var deferred = $q.defer(); + + StoridgeCluster.queryInfo() + .then(function success(response) { + var info = new StoridgeInfoModel(response.data); + deferred.resolve(info); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge information', err: err }); + }); + + return deferred.promise; + }; + + service.version = function() { + var deferred = $q.defer(); + + StoridgeCluster.queryVersion() + .then(function success(response) { + var version = response.data.version; + deferred.resolve(version); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge version', err: err }); + }); + + return deferred.promise; + }; + + service.events = function() { + var deferred = $q.defer(); + + StoridgeCluster.queryEvents() + .then(function success(response) { + var events = response.data.map(function(item) { + return new StoridgeEventModel(item); + }); + deferred.resolve(events); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge events', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/extensions/storidge/services/manager.js b/app/extensions/storidge/services/manager.js new file mode 100644 index 000000000..e57e7c235 --- /dev/null +++ b/app/extensions/storidge/services/manager.js @@ -0,0 +1,48 @@ +angular.module('extension.storidge') +.factory('StoridgeManager', ['$q', 'LocalStorage', 'SystemService', function StoridgeManagerFactory($q, LocalStorage, SystemService) { + 'use strict'; + var service = { + API: '' + }; + + service.init = function() { + var deferred = $q.defer(); + + var storedAPIURL = LocalStorage.getStoridgeAPIURL(); + if (storedAPIURL) { + service.API = storedAPIURL; + deferred.resolve(); + } else { + SystemService.info() + .then(function success(data) { + var endpointAddress = LocalStorage.getEndpointPublicURL(); + var storidgeAPIURL = ''; + if (endpointAddress) { + storidgeAPIURL = 'http://' + endpointAddress + ':8282'; + } else { + var managerIP = data.Swarm.NodeAddr; + storidgeAPIURL = 'http://' + managerIP + ':8282'; + } + + service.API = storidgeAPIURL; + LocalStorage.storeStoridgeAPIURL(storidgeAPIURL); + deferred.resolve(); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge API URL', err: err }); + }); + } + + return deferred.promise; + }; + + service.reset = function() { + LocalStorage.clearStoridgeAPIURL(); + }; + + service.StoridgeAPIURL = function() { + return service.API; + }; + + return service; +}]); diff --git a/app/extensions/storidge/services/nodeService.js b/app/extensions/storidge/services/nodeService.js new file mode 100644 index 000000000..ea6640b24 --- /dev/null +++ b/app/extensions/storidge/services/nodeService.js @@ -0,0 +1,30 @@ +angular.module('extension.storidge') +.factory('StoridgeNodeService', ['$q', 'StoridgeNodes', function StoridgeNodeServiceFactory($q, StoridgeNodes) { + 'use strict'; + var service = {}; + + service.nodes = function() { + var deferred = $q.defer(); + + StoridgeNodes.query() + .then(function success(response) { + var nodeData = response.data.nodes; + var nodes = []; + + for (var key in nodeData) { + if (nodeData.hasOwnProperty(key)) { + nodes.push(new StoridgeNodeModel(key, nodeData[key])); + } + } + + deferred.resolve(nodes); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge profiles', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/extensions/storidge/services/profileService.js b/app/extensions/storidge/services/profileService.js new file mode 100644 index 000000000..e758e1938 --- /dev/null +++ b/app/extensions/storidge/services/profileService.js @@ -0,0 +1,53 @@ +angular.module('extension.storidge') +.factory('StoridgeProfileService', ['$q', 'StoridgeProfiles', function StoridgeProfileServiceFactory($q, StoridgeProfiles) { + 'use strict'; + var service = {}; + + service.create = function(model) { + var payload = new StoridgeCreateProfileRequest(model); + return StoridgeProfiles.create(payload); + }; + + service.update = function(model) { + var payload = new StoridgeCreateProfileRequest(model); + return StoridgeProfiles.update(model.Name, payload); + }; + + service.delete = function(profileName) { + return StoridgeProfiles.delete(profileName); + }; + + service.profile = function(profileName) { + var deferred = $q.defer(); + + StoridgeProfiles.inspect(profileName) + .then(function success(response) { + var profile = new StoridgeProfileModel(profileName, response.data); + deferred.resolve(profile); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge profile details', err: err }); + }); + + return deferred.promise; + }; + + service.profiles = function() { + var deferred = $q.defer(); + + StoridgeProfiles.query() + .then(function success(response) { + var profiles = response.data.profiles.map(function (item) { + return new StoridgeProfileListModel(item); + }); + deferred.resolve(profiles); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve Storidge profiles', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/extensions/storidge/views/cluster/cluster.html b/app/extensions/storidge/views/cluster/cluster.html new file mode 100644 index 000000000..9dfae86a9 --- /dev/null +++ b/app/extensions/storidge/views/cluster/cluster.html @@ -0,0 +1,145 @@ + + + + + + + + Storidge + + + +
    +
    + + + + + + + + + + + + + + + + + + +
    Domain{{ clusterInfo.Domain }}
    Status {{ clusterInfo.Status }}
    Version{{ clusterVersion }}
    +
    +
    + Actions +
    +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + diff --git a/app/extensions/storidge/views/cluster/clusterController.js b/app/extensions/storidge/views/cluster/clusterController.js new file mode 100644 index 000000000..371abc8c4 --- /dev/null +++ b/app/extensions/storidge/views/cluster/clusterController.js @@ -0,0 +1,87 @@ +angular.module('extension.storidge') +.controller('StoridgeClusterController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeClusterService', 'StoridgeNodeService', 'StoridgeManager', 'ModalService', +function ($q, $scope, $state, Notifications, StoridgeClusterService, StoridgeNodeService, StoridgeManager, ModalService) { + + $scope.state = { + shutdownInProgress: false, + rebootInProgress: false + }; + + $scope.rebootCluster = function() { + ModalService.confirm({ + title: 'Are you sure?', + message: 'All the nodes in the cluster will reboot during the process. Do you want to reboot the Storidge cluster?', + buttons: { + confirm: { + label: 'Reboot', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { return; } + rebootCluster(); + } + }); + }; + + $scope.shutdownCluster = function() { + ModalService.confirm({ + title: 'Are you sure?', + message: 'All the nodes in the cluster will shutdown. Do you want to shutdown the Storidge cluster?', + buttons: { + confirm: { + label: 'Shutdown', + className: 'btn-danger' + } + }, + callback: function onConfirm(confirmed) { + if(!confirmed) { return; } + shutdownCluster(); + } + }); + }; + + function shutdownCluster() { + Notifications.error('Not implemented', {}, 'Not implemented yet'); + $state.reload(); + } + + function rebootCluster() { + $scope.state.rebootInProgress = true; + StoridgeClusterService.reboot() + .then(function success(data) { + Notifications.success('Cluster successfully rebooted'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to reboot cluster'); + }) + .finally(function final() { + $scope.state.rebootInProgress = false; + }); + } + + function initView() { + $q.all({ + info: StoridgeClusterService.info(), + version: StoridgeClusterService.version(), + nodes: StoridgeNodeService.nodes() + }) + .then(function success(data) { + $scope.clusterInfo = data.info; + $scope.clusterVersion = data.version; + $scope.clusterNodes = data.nodes; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve cluster information'); + }); + } + + StoridgeManager.init() + .then(function success() { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to communicate with Storidge API'); + }); +}]); diff --git a/app/extensions/storidge/views/monitor/monitor.html b/app/extensions/storidge/views/monitor/monitor.html new file mode 100644 index 000000000..b4ce1bfc4 --- /dev/null +++ b/app/extensions/storidge/views/monitor/monitor.html @@ -0,0 +1,201 @@ + + + + + + + + Storidge > Cluster monitoring + + + +
    +
    + + + +
    + +
    +
    + + + + + + + + + + + + + + + +
    Capacity available{{ ((info.FreeCapacity * 100) / info.TotalCapacity).toFixed(1) }}%
    Provisioned capacity + {{ info.ProvisionedCapacity | humansize }} + + + +
    Total capacity{{ info.TotalCapacity | humansize }}
    +
    +
    +
    +
    +
    + + + +
    + +
    +
    + + + + + + + + + + + + + + + +
    IOPS available{{ ((info.FreeIOPS * 100) / info.TotalIOPS).toFixed(1) }}%
    Provisioned IOPS + {{ info.ProvisionedIOPS | number }} + + + +
    Total IOPS{{ info.TotalIOPS | number }}
    +
    +
    +
    +
    +
    + + + +
    + +
    +
    + + + + + + + + + + + + + + + +
    Bandwidth available{{ ((info.FreeBandwidth * 100) / info.TotalBandwidth).toFixed(1) }}%
    Provisioned bandwidth + {{ info.ProvisionedBandwidth | humansize }} + + + +
    Total bandwidth{{ info.TotalBandwidth | humansize }} /s
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + diff --git a/app/extensions/storidge/views/monitor/monitorController.js b/app/extensions/storidge/views/monitor/monitorController.js new file mode 100644 index 000000000..64c60b7f1 --- /dev/null +++ b/app/extensions/storidge/views/monitor/monitorController.js @@ -0,0 +1,108 @@ +angular.module('extension.storidge') +.controller('StoridgeMonitorController', ['$q', '$scope', '$interval', '$document', 'Notifications', 'StoridgeClusterService', 'StoridgeChartService', 'StoridgeManager', 'ModalService', +function ($q, $scope, $interval, $document, Notifications, StoridgeClusterService, StoridgeChartService, StoridgeManager, ModalService) { + + $scope.$on('$destroy', function() { + stopRepeater(); + }); + + function stopRepeater() { + var repeater = $scope.repeater; + if (angular.isDefined(repeater)) { + $interval.cancel(repeater); + repeater = null; + } + } + + function updateIOPSChart(info, chart) { + var usedIOPS = info.UsedIOPS; + var label = moment(new Date()).format('HH:mm:ss'); + + StoridgeChartService.UpdateChart(label, usedIOPS, chart); + } + + function updateBandwithChart(info, chart) { + var usedBandwidth = info.UsedBandwidth; + var label = moment(new Date()).format('HH:mm:ss'); + + StoridgeChartService.UpdateChart(label, usedBandwidth, chart); + } + + function updateCapacityChart(info, chart) { + var usedCapacity = info.UsedCapacity; + var freeCapacity = info.FreeCapacity; + + StoridgeChartService.UpdatePieChart('Free', freeCapacity, chart); + StoridgeChartService.UpdatePieChart('Used', usedCapacity, chart); + } + + function setUpdateRepeater(iopsChart, bandwidthChart, capacityChart) { + var refreshRate = 5000; + $scope.repeater = $interval(function() { + $q.all({ + events: StoridgeClusterService.events(), + info: StoridgeClusterService.info() + }) + .then(function success(data) { + $scope.events = data.events; + var info = data.info; + $scope.info = info; + updateIOPSChart(info, iopsChart); + updateBandwithChart(info, bandwidthChart); + updateCapacityChart(info, capacityChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve cluster information'); + }); + }, refreshRate); + } + + function startViewUpdate(iopsChart, bandwidthChart, capacityChart) { + $q.all({ + events: StoridgeClusterService.events(), + info: StoridgeClusterService.info() + }) + .then(function success(data) { + $scope.events = data.events; + var info = data.info; + $scope.info = info; + updateIOPSChart(info, iopsChart); + updateBandwithChart(info, bandwidthChart); + updateCapacityChart(info, capacityChart); + setUpdateRepeater(iopsChart, bandwidthChart, capacityChart); + }) + .catch(function error(err) { + stopRepeater(); + Notifications.error('Failure', err, 'Unable to retrieve cluster information'); + }); + } + + function initCharts() { + var iopsChartCtx = $('#iopsChart'); + var iopsChart = StoridgeChartService.CreateIOPSChart(iopsChartCtx); + + var bandwidthChartCtx = $('#bandwithChart'); + var bandwidthChart = StoridgeChartService.CreateBandwidthChart(bandwidthChartCtx); + + var capacityChartCtx = $('#capacityChart'); + var capacityChart = StoridgeChartService.CreateCapacityChart(capacityChartCtx); + + startViewUpdate(iopsChart, bandwidthChart, capacityChart); + } + + function initView() { + + $document.ready(function() { + initCharts(); + }); + } + + StoridgeManager.init() + .then(function success() { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to communicate with Storidge API'); + }); +}]); diff --git a/app/extensions/storidge/views/profiles/create/createProfile.html b/app/extensions/storidge/views/profiles/create/createProfile.html new file mode 100644 index 000000000..621a8332c --- /dev/null +++ b/app/extensions/storidge/views/profiles/create/createProfile.html @@ -0,0 +1,200 @@ + + + + Storidge > Profiles > Add profile + + + +
    +
    + + +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    This field is required.

    +
    +
    +
    + +
    + Profile configuration +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    This field is required.

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

    This field is required.

    +

    Minimum value for capacity: 1.

    +

    Maximum value for capacity: 64000.

    +
    +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    +
    + IOPS +
    +
    +
    + + +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    A value is required for Min IOPS.

    +

    Minimum value for Min IOPS: 30.

    +

    Maximum value for Min IOPS: 999999.

    +
    +
    +
    +
    +
    +
    +

    A value is required for Max IOPS.

    +

    Minimum value for Max IOPS: 30.

    +

    Maximum value for Max IOPS: 999999.

    +
    +
    +
    +
    + + +
    +
    + Bandwidth +
    +
    +
    + + +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    A value is required for Min bandwidth.

    +

    Minimum value for Min bandwidth: 1.

    +

    Maximum value for Min bandwidth: 5000.

    +
    +
    +
    +
    +
    +
    +

    A value is required for Max bandwidth.

    +

    Minimum value for Max bandwidth: 1.

    +

    Maximum value for Max bandwidth: 5000.

    +
    +
    +
    +
    + +
    + Actions +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    diff --git a/app/extensions/storidge/views/profiles/create/createProfileController.js b/app/extensions/storidge/views/profiles/create/createProfileController.js new file mode 100644 index 000000000..506635697 --- /dev/null +++ b/app/extensions/storidge/views/profiles/create/createProfileController.js @@ -0,0 +1,72 @@ +angular.module('extension.storidge') +.controller('CreateProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService', 'StoridgeManager', +function ($scope, $state, $transition$, Notifications, StoridgeProfileService, StoridgeManager) { + + $scope.state = { + NoLimit: true, + LimitIOPS: false, + LimitBandwidth: false, + ManualInputDirectory: false, + actionInProgress: false + }; + + $scope.RedundancyOptions = [ + { value: 2, label: '2-copy' }, + { value: 3, label: '3-copy' } + ]; + + $scope.create = function () { + var profile = $scope.model; + + if (!$scope.state.LimitIOPS) { + delete profile.MinIOPS; + delete profile.MaxIOPS; + } + + if (!$scope.state.LimitBandwidth) { + delete profile.MinBandwidth; + delete profile.MaxBandwidth; + } + + $scope.state.actionInProgress = true; + StoridgeProfileService.create(profile) + .then(function success(data) { + Notifications.success('Profile successfully created'); + $state.go('storidge.profiles'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create profile'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + $scope.updatedName = function() { + if (!$scope.state.ManualInputDirectory) { + var profile = $scope.model; + profile.Directory = '/cio/' + profile.Name; + } + }; + + $scope.updatedDirectory = function() { + if (!$scope.state.ManualInputDirectory) { + $scope.state.ManualInputDirectory = true; + } + }; + + function initView() { + var profile = new StoridgeProfileDefaultModel(); + profile.Name = $transition$.params().profileName; + profile.Directory = '/cio/' + profile.Name; + $scope.model = profile; + } + + StoridgeManager.init() + .then(function success() { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to communicate with Storidge API'); + }); +}]); diff --git a/app/extensions/storidge/views/profiles/edit/editProfile.html b/app/extensions/storidge/views/profiles/edit/editProfile.html new file mode 100644 index 000000000..8aca0bbb6 --- /dev/null +++ b/app/extensions/storidge/views/profiles/edit/editProfile.html @@ -0,0 +1,198 @@ + + + + Storidge > Profiles > {{ profile.Name }} + + + +
    +
    + + +
    + +
    + +
    + +
    +
    + +
    + Profile configuration +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    This field is required.

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

    This field is required.

    +

    Minimum value for capacity: 1.

    +

    Maximum value for capacity: 64000.

    +
    +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    +
    + IOPS +
    +
    +
    + + +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    A value is required for Min IOPS.

    +

    Minimum value for Min IOPS: 30.

    +

    Maximum value for Min IOPS: 999999.

    +
    +
    +
    +
    +
    +
    +

    A value is required for Max IOPS.

    +

    Minimum value for Max IOPS: 30.

    +

    Maximum value for Max IOPS: 999999.

    +
    +
    +
    +
    + + +
    +
    + Bandwidth +
    +
    +
    + + +
    +
    +
    + +
    + +
    + +
    + +
    +
    +
    +
    +
    +

    A value is required for Min bandwidth.

    +

    Minimum value for Min bandwidth: 1.

    +

    Maximum value for Min bandwidth: 5000.

    +
    +
    +
    +
    +
    +
    +

    A value is required for Max bandwidth.

    +

    Minimum value for Max bandwidth: 1.

    +

    Maximum value for Max bandwidth: 5000.

    +
    +
    +
    +
    + + +
    + Actions +
    +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    diff --git a/app/extensions/storidge/views/profiles/edit/editProfileController.js b/app/extensions/storidge/views/profiles/edit/editProfileController.js new file mode 100644 index 000000000..d36d26738 --- /dev/null +++ b/app/extensions/storidge/views/profiles/edit/editProfileController.js @@ -0,0 +1,98 @@ +angular.module('extension.storidge') +.controller('EditProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService', 'StoridgeManager', 'ModalService', +function ($scope, $state, $transition$, Notifications, StoridgeProfileService, StoridgeManager, ModalService) { + + $scope.state = { + NoLimit: false, + LimitIOPS: false, + LimitBandwidth: false, + updateInProgress: false, + deleteInProgress: false + }; + + $scope.RedundancyOptions = [ + { value: 2, label: '2-copy' }, + { value: 3, label: '3-copy' } + ]; + + $scope.update = function() { + + var profile = $scope.profile; + + if (!$scope.state.LimitIOPS) { + delete profile.MinIOPS; + delete profile.MaxIOPS; + } + + if (!$scope.state.LimitBandwidth) { + delete profile.MinBandwidth; + delete profile.MaxBandwidth; + } + + $scope.state.updateInProgress = true; + StoridgeProfileService.update(profile) + .then(function success(data) { + Notifications.success('Profile successfully updated'); + $state.go('storidge.profiles'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update profile'); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + }; + + $scope.delete = function() { + ModalService.confirmDeletion( + 'Do you want to remove this profile?', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteProfile(); + } + ); + }; + + function deleteProfile() { + var profile = $scope.profile; + + $scope.state.deleteInProgress = true; + StoridgeProfileService.delete(profile.Name) + .then(function success(data) { + Notifications.success('Profile successfully deleted'); + $state.go('storidge.profiles'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete profile'); + }) + .finally(function final() { + $scope.state.deleteInProgress = false; + }); + } + + function initView() { + StoridgeProfileService.profile($transition$.params().id) + .then(function success(data) { + var profile = data; + if ((profile.MinIOPS && profile.MinIOPS !== 0) || (profile.MaxIOPS && profile.MaxIOPS !== 0)) { + $scope.state.LimitIOPS = true; + } else if ((profile.MinBandwidth && profile.MinBandwidth !== 0) || (profile.MaxBandwidth && profile.MaxBandwidth !== 0)) { + $scope.state.LimitBandwidth = true; + } else { + $scope.state.NoLimit = true; + } + $scope.profile = profile; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve profile details'); + }); + } + + StoridgeManager.init() + .then(function success() { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to communicate with Storidge API'); + }); +}]); diff --git a/app/extensions/storidge/views/profiles/profiles.html b/app/extensions/storidge/views/profiles/profiles.html new file mode 100644 index 000000000..3eafa095e --- /dev/null +++ b/app/extensions/storidge/views/profiles/profiles.html @@ -0,0 +1,123 @@ + + + + + + + + Storidge > Profiles + + + +
    +
    + + + + +
    + +
    + +
    + +
    +
    + + +
    +
    + Note: The profile will be created using the default properties. +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + + diff --git a/app/extensions/storidge/views/profiles/profilesController.js b/app/extensions/storidge/views/profiles/profilesController.js new file mode 100644 index 000000000..f3bd1c441 --- /dev/null +++ b/app/extensions/storidge/views/profiles/profilesController.js @@ -0,0 +1,70 @@ +angular.module('extension.storidge') +.controller('StoridgeProfilesController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeProfileService', 'StoridgeManager', +function ($q, $scope, $state, Notifications, StoridgeProfileService, StoridgeManager) { + + $scope.state = { + actionInProgress: false + }; + + $scope.formValues = { + Name: '' + }; + + $scope.removeAction = function(selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (profile) { + StoridgeProfileService.delete(profile.Name) + .then(function success() { + Notifications.success('Profile successfully removed', profile.Name); + var index = $scope.profiles.indexOf(profile); + $scope.profiles.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove profile'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + $scope.create = function() { + var model = new StoridgeProfileDefaultModel(); + model.Name = $scope.formValues.Name; + model.Directory = model.Directory + model.Name; + + $scope.state.actionInProgress = true; + StoridgeProfileService.create(model) + .then(function success(data) { + Notifications.success('Profile successfully created'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create profile'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function initView() { + StoridgeProfileService.profiles() + .then(function success(data) { + $scope.profiles = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve profiles'); + }); + } + + StoridgeManager.init() + .then(function success() { + initView(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to communicate with Storidge API'); + }); +}]); diff --git a/app/filters/filters.js b/app/filters/filters.js index e7542805b..a30a71f87 100644 --- a/app/filters/filters.js +++ b/app/filters/filters.js @@ -66,7 +66,7 @@ angular.module('portainer.filters') labelStyle = 'primary'; } else if (includeString(status, ['running'])) { labelStyle = 'success'; - } + } return labelStyle; }; }) @@ -345,4 +345,13 @@ angular.module('portainer.filters') return function (createdBy) { return createdBy.replace('/bin/sh -c #(nop) ', '').replace('/bin/sh -c ', 'RUN '); }; +}) +.filter('trimshasum', function () { + 'use strict'; + return function (imageName) { + if (imageName.indexOf('sha256:') === 0) { + return imageName.substring(7, 19); + } + return _.split(imageName, '@sha256')[0]; + }; }); diff --git a/app/helpers/serviceHelper.js b/app/helpers/serviceHelper.js index b25def767..38adafe0d 100644 --- a/app/helpers/serviceHelper.js +++ b/app/helpers/serviceHelper.js @@ -172,8 +172,7 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe // e.g 3600000000000 nanoseconds = 1h helper.translateNanosToHumanDuration = function(nanos) { - var humanDuration = '0s'; - + var humanDuration = '0s'; var conversionFromNano = {}; conversionFromNano['ns'] = 1; conversionFromNano['us'] = conversionFromNano['ns'] * 1000; @@ -186,10 +185,62 @@ angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHe if ( nanos % conversionFromNano[unit] === 0 && (nanos / conversionFromNano[unit]) > 0) { humanDuration = (nanos / conversionFromNano[unit]) + unit; } - }); - + }); return humanDuration; }; + helper.translateLogDriverOptsToKeyValue = function(logOptions) { + var options = []; + if (logOptions) { + Object.keys(logOptions).forEach(function(key) { + options.push({ + key: key, + value: logOptions[key], + originalKey: key, + originalValue: logOptions[key], + added: true + }); + }); + } + return options; + }; + + helper.translateKeyValueToLogDriverOpts = function(keyValueLogDriverOpts) { + var options = {}; + if (keyValueLogDriverOpts) { + keyValueLogDriverOpts.forEach(function(option) { + if (option.key && option.key !== '' && option.value && option.value !== '') { + options[option.key] = option.value; + } + }); + } + return options; + }; + + helper.translateHostsEntriesToHostnameIP = function(entries) { + var ipHostEntries = []; + if (entries) { + entries.forEach(function(entry) { + if (entry.indexOf(' ') && entry.split(' ').length === 2) { + var keyValue = entry.split(' '); + ipHostEntries.push({ hostname: keyValue[1], ip: keyValue[0]}); + } + }); + } + return ipHostEntries; + }; + + helper.translateHostnameIPToHostsEntries = function(entries) { + var ipHostEntries = []; + if (entries) { + entries.forEach(function(entry) { + if (entry.ip && entry.hostname) { + ipHostEntries.push(entry.ip + ' ' + entry.hostname); + } + }); + } + return ipHostEntries; + }; + return helper; }]); diff --git a/app/helpers/templateHelper.js b/app/helpers/templateHelper.js index d6ad6bc63..288bed577 100644 --- a/app/helpers/templateHelper.js +++ b/app/helpers/templateHelper.js @@ -18,7 +18,8 @@ angular.module('portainer.helpers') Privileged: false, ExtraHosts: [] }, - Volumes: {} + Volumes: {}, + Labels: {} }; }; @@ -46,6 +47,16 @@ angular.module('portainer.helpers') return portConfiguration; }; + helper.updateContainerConfigurationWithLabels = function(labelsArray) { + var labels = {}; + labelsArray.forEach(function (l) { + if (l.name && l.value) { + labels[l.name] = l.value; + } + }); + return labels; + }; + helper.EnvToStringArray = function(templateEnvironment, containerMapping) { var env = []; templateEnvironment.forEach(function(envvar) { diff --git a/app/models/api/stack.js b/app/models/api/stack.js index 30b130943..7d1310246 100644 --- a/app/models/api/stack.js +++ b/app/models/api/stack.js @@ -2,7 +2,7 @@ function StackViewModel(data) { this.Id = data.Id; this.Name = data.Name; this.Checked = false; - this.Env = data.Env; + this.Env = data.Env ? data.Env : []; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } diff --git a/app/models/api/template.js b/app/models/api/template.js index 76a77d961..c4d8c3b44 100644 --- a/app/models/api/template.js +++ b/app/models/api/template.js @@ -14,7 +14,9 @@ function TemplateViewModel(data) { this.Privileged = data.privileged ? data.privileged : false; this.Interactive = data.interactive ? data.interactive : false; this.RestartPolicy = data.restart_policy ? data.restart_policy : 'always'; + this.Labels = data.labels ? data.labels : []; this.Volumes = []; + if (data.volumes) { this.Volumes = data.volumes.map(function (v) { // @DEPRECATED: New volume definition introduced @@ -43,5 +45,5 @@ function TemplateViewModel(data) { }; }); } - this.Hosts = data.hosts ? data.hosts : []; + this.Hosts = data.hosts ? data.hosts : []; } diff --git a/app/models/docker/container.js b/app/models/docker/container.js index b4e65a014..c37f2fe93 100644 --- a/app/models/docker/container.js +++ b/app/models/docker/container.js @@ -34,7 +34,5 @@ function ContainerViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } - } else { - this.ResourceControl = { Ownership: 'public' }; } } diff --git a/app/models/docker/network.js b/app/models/docker/network.js index 0a756fa5b..baa5ab984 100644 --- a/app/models/docker/network.js +++ b/app/models/docker/network.js @@ -19,7 +19,5 @@ function NetworkViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } - } else { - this.ResourceControl = { Ownership: 'public' }; } } diff --git a/app/models/docker/service.js b/app/models/docker/service.js index c04c7611d..116590df4 100644 --- a/app/models/docker/service.js +++ b/app/models/docker/service.js @@ -41,6 +41,15 @@ function ServiceViewModel(data, runningTasks, allTasks, nodes) { this.RestartMaxAttempts = 0; this.RestartWindow = 0; } + + if (data.Spec.TaskTemplate.LogDriver) { + this.LogDriverName = data.Spec.TaskTemplate.LogDriver.Name || ''; + this.LogDriverOpts = data.Spec.TaskTemplate.LogDriver.Options || []; + } else { + this.LogDriverName = ''; + this.LogDriverOpts = []; + } + this.Constraints = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Constraints || [] : []; this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : []; this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : []; diff --git a/app/models/docker/volume.js b/app/models/docker/volume.js index 611a4cf4c..5e2701b70 100644 --- a/app/models/docker/volume.js +++ b/app/models/docker/volume.js @@ -14,7 +14,5 @@ function VolumeViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } - } else { - this.ResourceControl = { Ownership: 'public' }; } } diff --git a/app/services/api/stackService.js b/app/services/api/stackService.js index e206b620d..64b99ec01 100644 --- a/app/services/api/stackService.js +++ b/app/services/api/stackService.js @@ -167,8 +167,8 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.updateStack = function(id, stackFile, env) { - return Stack.update({ id: id, StackFileContent: stackFile, Env: env }).$promise; + service.updateStack = function(id, stackFile, env, prune) { + return Stack.update({ id: id, StackFileContent: stackFile, Env: env, Prune: prune}).$promise; }; return service; diff --git a/app/services/chartService.js b/app/services/chartService.js index 5aa2ac1db..6510f2f5d 100644 --- a/app/services/chartService.js +++ b/app/services/chartService.js @@ -87,8 +87,8 @@ angular.module('portainer.services') label: 'TX on eth0', data: [], fill: false, - backgroundColor: 'rgba(255,180,174,0.5)', - borderColor: 'rgba(255,180,174,0.7)', + backgroundColor: 'rgba(255,180,174,0.4)', + borderColor: 'rgba(255,180,174,0.6)', pointBackgroundColor: 'rgba(255,180,174,1)', pointBorderColor: 'rgba(255,180,174,1)', pointRadius: 2, diff --git a/app/services/docker/pluginService.js b/app/services/docker/pluginService.js index a539105ad..b7d94d4e4 100644 --- a/app/services/docker/pluginService.js +++ b/app/services/docker/pluginService.js @@ -60,5 +60,9 @@ angular.module('portainer.services') return servicePlugins(systemOnly, 'Network', 'docker.networkdriver/1.0'); }; + service.loggingPlugins = function(systemOnly) { + return servicePlugins(systemOnly, 'Log', 'docker.logdriver/1.0'); + }; + return service; }]); diff --git a/app/services/extensionManager.js b/app/services/extensionManager.js new file mode 100644 index 000000000..32dfe9f0f --- /dev/null +++ b/app/services/extensionManager.js @@ -0,0 +1,36 @@ +angular.module('portainer.services') +.factory('ExtensionManager', ['$q', 'PluginService', 'StoridgeManager', function ExtensionManagerFactory($q, PluginService, StoridgeManager) { + 'use strict'; + var service = {}; + + service.init = function() { + return $q.all( + StoridgeManager.init() + ); + }; + + service.reset = function() { + StoridgeManager.reset(); + }; + + service.extensions = function() { + var deferred = $q.defer(); + var extensions = []; + + PluginService.volumePlugins() + .then(function success(data) { + var volumePlugins = data; + if (_.includes(volumePlugins, 'cio:latest')) { + extensions.push('storidge'); + } + deferred.resolve(extensions); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve extensions', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/services/localStorage.js b/app/services/localStorage.js index da9f9b670..144ea551e 100644 --- a/app/services/localStorage.js +++ b/app/services/localStorage.js @@ -41,6 +41,15 @@ angular.module('portainer.services') getPaginationLimit: function(key) { return localStorageService.cookie.get('pagination_' + key); }, + storeStoridgeAPIURL: function(url) { + localStorageService.set('STORIDGE_API_URL', url); + }, + getStoridgeAPIURL: function() { + return localStorageService.get('STORIDGE_API_URL'); + }, + clearStoridgeAPIURL: function() { + return localStorageService.remove('STORIDGE_API_URL'); + }, getDataTableOrder: function(key) { return localStorageService.get('datatable_order_' + key); }, diff --git a/app/services/modalService.js b/app/services/modalService.js index 81b0b84a9..4c81bdcc6 100644 --- a/app/services/modalService.js +++ b/app/services/modalService.js @@ -156,5 +156,19 @@ angular.module('portainer.services') }); }; + service.confirmServiceForceUpdate = function(message, callback) { + service.confirm({ + title: 'Are you sure ?', + message: message, + buttons: { + confirm: { + label: 'Update', + className: 'btn-primary' + } + }, + callback: callback + }); + }; + return service; }]); diff --git a/app/services/stateManager.js b/app/services/stateManager.js index bd5e70d9e..36b3dc3cb 100644 --- a/app/services/stateManager.js +++ b/app/services/stateManager.js @@ -1,5 +1,5 @@ angular.module('portainer.services') -.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService) { +.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', 'ExtensionManager', 'APPLICATION_CACHE_VALIDITY', function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, ExtensionManager, APPLICATION_CACHE_VALIDITY) { 'use strict'; var manager = {}; @@ -34,6 +34,41 @@ angular.module('portainer.services') LocalStorage.storeApplicationState(state.application); }; + function assignStateFromStatusAndSettings(status, settings) { + state.application.authentication = status.Authentication; + state.application.analytics = status.Analytics; + state.application.endpointManagement = status.EndpointManagement; + state.application.version = status.Version; + state.application.logo = settings.LogoURL; + state.application.displayDonationHeader = settings.DisplayDonationHeader; + state.application.displayExternalContributors = settings.DisplayExternalContributors; + state.application.validity = moment().unix(); + } + + function loadApplicationState() { + var deferred = $q.defer(); + + $q.all({ + settings: SettingsService.publicSettings(), + status: StatusService.status() + }) + .then(function success(data) { + var status = data.status; + var settings = data.settings; + assignStateFromStatusAndSettings(status, settings); + LocalStorage.storeApplicationState(state.application); + deferred.resolve(state); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve server settings and status', err: err}); + }) + .finally(function final() { + state.loading = false; + }); + + return deferred.promise; + } + manager.initialize = function () { var deferred = $q.defer(); @@ -44,32 +79,28 @@ angular.module('portainer.services') var applicationState = LocalStorage.getApplicationState(); if (applicationState) { - state.application = applicationState; - state.loading = false; - deferred.resolve(state); + var now = moment().unix(); + var cacheValidity = now - applicationState.validity; + if (cacheValidity > APPLICATION_CACHE_VALIDITY) { + loadApplicationState() + .then(function success(data) { + deferred.resolve(state); + }) + .catch(function error(err) { + deferred.reject(err); + }); + } else { + state.application = applicationState; + state.loading = false; + deferred.resolve(state); + } } else { - $q.all({ - settings: SettingsService.publicSettings(), - status: StatusService.status() - }) + loadApplicationState() .then(function success(data) { - var status = data.status; - var settings = data.settings; - state.application.authentication = status.Authentication; - state.application.analytics = status.Analytics; - state.application.endpointManagement = status.EndpointManagement; - state.application.version = status.Version; - state.application.logo = settings.LogoURL; - state.application.displayDonationHeader = settings.DisplayDonationHeader; - state.application.displayExternalContributors = settings.DisplayExternalContributors; - LocalStorage.storeApplicationState(state.application); deferred.resolve(state); }) .catch(function error(err) { - deferred.reject({msg: 'Unable to retrieve server settings and status', err: err}); - }) - .finally(function final() { - state.loading = false; + deferred.reject(err); }); } @@ -83,13 +114,15 @@ angular.module('portainer.services') } $q.all({ info: SystemService.info(), - version: SystemService.version() + version: SystemService.version(), + extensions: ExtensionManager.extensions() }) .then(function success(data) { var endpointMode = InfoHelper.determineEndpointMode(data.info); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; state.endpoint.apiVersion = endpointAPIVersion; + state.endpoint.extensions = data.extensions; LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); }) diff --git a/app/services/templateService.js b/app/services/templateService.js index 89694da59..ca08f756c 100644 --- a/app/services/templateService.js +++ b/app/services/templateService.js @@ -54,6 +54,7 @@ angular.module('portainer.services') var consoleConfiguration = TemplateHelper.getConsoleConfiguration(template.Interactive); configuration.OpenStdin = consoleConfiguration.openStdin; configuration.Tty = consoleConfiguration.tty; + configuration.Labels = TemplateHelper.updateContainerConfigurationWithLabels(template.Labels); return configuration; }; diff --git a/assets/css/app.css b/assets/css/app.css index 341cdcd67..9840c1f67 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -79,6 +79,10 @@ a[ng-click]{ margin-right: 5px; } +.space-left { + margin-left: 5px; +} + .tooltip.portainer-tooltip .tooltip-inner { font-family: Montserrat; background-color: #ffffff; @@ -377,7 +381,27 @@ ul.sidebar .sidebar-list .sidebar-sublist a { text-indent: 35px; font-size: 12px; color: #b2bfdc; - line-height: 40px; + line-height: 36px; +} + +ul.sidebar .sidebar-title { + line-height: 36px; +} +ul.sidebar .sidebar-title .form-control { + height: 36px; + padding: 6px 12px; +} + +ul.sidebar .sidebar-list { + height: 36px; +} + +ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { + line-height: 36px; +} + +ul.sidebar .sidebar-list .menu-icon { + line-height: 36px; } ul.sidebar .sidebar-list .sidebar-sublist a.active { @@ -386,27 +410,27 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { background: #2d3e63; } -@media (max-height: 683px) { +@media (max-height: 785px) { ul.sidebar .sidebar-title { - line-height: 28px; + line-height: 26px; } ul.sidebar .sidebar-title .form-control { - height: 28px; - padding: 4px 8px; + height: 26px; + padding: 3px 6px; } ul.sidebar .sidebar-list { - height: 28px; + height: 26px; } ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a { font-size: 12px; - line-height: 28px; + line-height: 26px; } ul.sidebar .sidebar-list .menu-icon { - line-height: 28px; + line-height: 26px; } } -@media(min-height: 684px) and (max-height: 850px) { +@media(min-height: 786px) and (max-height: 924px) { ul.sidebar .sidebar-title { line-height: 30px; } diff --git a/build/linux/Dockerfile b/build/linux/Dockerfile index e16144acc..3bbc694f9 100644 --- a/build/linux/Dockerfile +++ b/build/linux/Dockerfile @@ -9,3 +9,5 @@ WORKDIR / EXPOSE 9000 ENTRYPOINT ["/portainer"] + +HEALTHCHECK --start-period=10ms --interval=30s --timeout=5s --retries=3 CMD ["/portainer", "-c"] diff --git a/codefresh.yml b/codefresh.yml index c669dbaef..fc22891ed 100644 --- a/codefresh.yml +++ b/codefresh.yml @@ -13,9 +13,8 @@ steps: image: portainer/angular-builder:latest working_directory: ${{build_backend}} commands: - - npm install -g bower grunt grunt-cli && npm install - - bower install --allow-root - - grunt build-webapp + - yarn + - yarn grunt build-webapp - mv api/cmd/portainer/portainer dist/ download_docker_binary: diff --git a/distribution/portainer.spec b/distribution/portainer.spec index 3509cf0ea..2e65fa4d0 100644 --- a/distribution/portainer.spec +++ b/distribution/portainer.spec @@ -1,5 +1,5 @@ Name: portainer -Version: 1.15.5 +Version: 1.16.0 Release: 0 License: Zlib Summary: A lightweight docker management UI diff --git a/gruntfile.js b/gruntfile.js index cde30f4da..6974ef870 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -116,7 +116,7 @@ gruntfile_cfg.src = { js: ['app/**/__module.js', 'app/**/*.js', '!app/**/*.spec.js'], jsTpl: ['<%= distdir %>/templates/**/*.js'], html: ['index.html'], - tpl: ['app/components/**/*.html', 'app/directives/**/*.html'], + tpl: ['app/components/**/*.html', 'app/directives/**/*.html', 'app/extensions/**/*.html'], css: ['assets/css/app.css', 'app/**/*.css'] }; diff --git a/package.json b/package.json index 80dabfe94..b4f8a52a2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "1.15.5", + "version": "1.16.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" @@ -23,37 +23,38 @@ "node": ">= 0.8.4" }, "dependencies": { + "@uirouter/angularjs": "~1.0.6", "angular": "~1.5.0", "angular-cookies": "~1.5.0", - "angular-ui-bootstrap": "~2.5.0", - "angular-sanitize": "~1.5.0", + "angular-google-analytics": "github:revolunet/angular-google-analytics#~1.1.9", + "angular-json-tree": "1.0.1", + "angular-jwt": "~0.1.8", + "angular-loading-bar": "~0.9.0", + "angular-local-storage": "~0.5.2", + "angular-messages": "~1.5.0", "angular-mocks": "~1.5.0", "angular-resource": "~1.5.0", - "ui-select": "~0.19.6", + "angular-sanitize": "~1.5.0", + "angular-ui-bootstrap": "~2.5.0", "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": "github:revolunet/angular-google-analytics#~1.1.9", - "bootstrap": "~3.3.6", - "filesize": "~3.3.0", - "jquery": "1.11.1", - "lodash": "4.12.0", - "rdash-ui": "1.0.*", - "moment": "~2.14.1", - "font-awesome": "~4.7.0", - "ng-file-upload": "~12.2.13", - "splitargs": "github:deviantony/splitargs#~0.2.0", - "bootbox": "^4.4.0", - "isteven-angular-multiselect": "~4.0.0", - "toastr": "github:CodeSeven/toastr#~2.1.3", - "xterm": "~2.8.1", - "chart.js": "~2.6.0", "angularjs-slider": "^6.4.0", - "@uirouter/angularjs": "~1.0.6", + "bootbox": "^4.4.0", + "bootstrap": "~3.3.6", + "chart.js": "~2.6.0", "codemirror": "~5.30.0", + "filesize": "~3.3.0", + "font-awesome": "~4.7.0", + "isteven-angular-multiselect": "~4.0.0", + "jquery": "1.11.1", "js-yaml": "~3.10.0", - "angular-loading-bar": "~0.9.0" + "lodash": "4.12.0", + "moment": "~2.14.1", + "ng-file-upload": "~12.2.13", + "rdash-ui": "1.0.*", + "splitargs": "github:deviantony/splitargs#~0.2.0", + "toastr": "github:CodeSeven/toastr#~2.1.3", + "ui-select": "~0.19.6", + "xterm": "~2.8.1" }, "devDependencies": { "autoprefixer": "^7.1.1", diff --git a/vendor.yml b/vendor.yml index de58b9b08..0e0926014 100644 --- a/vendor.yml +++ b/vendor.yml @@ -69,6 +69,7 @@ angular: - node_modules/angular-google-analytics/dist/angular-google-analytics.js - node_modules/angular-jwt/dist/angular-jwt.js - node_modules/angular-local-storage/dist/angular-local-storage.js + - node_modules/angular-messages/angular-messages.js - node_modules/angular-resource/angular-resource.js - node_modules/angular-sanitize/angular-sanitize.js - node_modules/ui-select/dist/select.js @@ -86,6 +87,7 @@ angular: - node_modules/angular-google-analytics/dist/angular-google-analytics.min.js - node_modules/angular-jwt/dist/angular-jwt.min.js - node_modules/angular-local-storage/dist/angular-local-storage.min.js + - node_modules/angular-messages/angular-messages.min.js - node_modules/angular-resource/angular-resource.min.js - node_modules/angular-sanitize/angular-sanitize.min.js - node_modules/ui-select/dist/select.min.js diff --git a/yarn.lock b/yarn.lock index 28c81b08c..6aa370d3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -113,6 +113,10 @@ angular-local-storage@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/angular-local-storage/-/angular-local-storage-0.5.2.tgz#7079beb0aa5ca91386d223125efefd13ca0ecd0c" +angular-messages@~1.5.0: + version "1.5.11" + resolved "https://registry.yarnpkg.com/angular-messages/-/angular-messages-1.5.11.tgz#ea99f0163594fcb0a2db701b3038339250decc90" + angular-mocks@~1.5.0: version "1.5.11" resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.5.11.tgz#a0e1dd0ea55fd77ee7a757d75536c5e964c86f81" @@ -2363,7 +2367,7 @@ istanbul@~0.1.40: wordwrap "0.0.x" isteven-angular-multiselect@~4.0.0: - version v4.0.0 + version "4.0.0" resolved "https://registry.yarnpkg.com/isteven-angular-multiselect/-/isteven-angular-multiselect-4.0.0.tgz#70276da5ff3bc4d9a0887dc585ee26a1a26a8ed6" jquery@1.11.1: