Merge branch 'release/1.15.1'

pull/1186/merge 1.15.1
Anthony Lapenna 2017-11-08 08:29:05 +01:00
commit ff82d4320f
93 changed files with 2056 additions and 281 deletions

View File

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer"
"os"
"path/filepath"
"strings"
"gopkg.in/alecthomas/kingpin.v2"
@ -54,6 +55,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
}
kingpin.Parse()
if !filepath.IsAbs(*flags.Assets) {
ex, err := os.Executable()
if err != nil {
panic(err)
}
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
}
return flags, nil
}

View File

@ -5,7 +5,7 @@ package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "."
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"

View File

@ -3,7 +3,7 @@ package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "."
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLSVerify = "false"

View File

@ -127,7 +127,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
if err == portainer.ErrSettingsNotFound {
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayExternalContributors: true,
DisplayExternalContributors: false,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},

View File

@ -2,6 +2,7 @@ package exec
import (
"bytes"
"os"
"os/exec"
"path"
"runtime"
@ -21,26 +22,68 @@ func NewStackManager(binaryPath string) *StackManager {
}
}
// Deploy will execute the Docker stack deploy command
// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
for _, registry := range registries {
if registry.Authentication {
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
err := runCommandAndCaptureStdErr(command, registryArgs, nil)
if err != nil {
return err
}
}
}
if dockerhub.Authentication {
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil)
if err != nil {
return err
}
}
return nil
}
// Logout executes the docker logout command.
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil)
}
// Deploy executes the docker stack deploy command.
func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
return runCommandAndCaptureStdErr(command, args)
env := make([]string, 0)
for _, envvar := range stack.Env {
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env)
}
// Remove will execute the Docker stack rm command
// Remove executes the docker stack rm command.
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args)
return runCommandAndCaptureStdErr(command, args, nil)
}
func runCommandAndCaptureStdErr(command string, args []string) error {
func runCommandAndCaptureStdErr(command string, args []string, env []string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
if env != nil {
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, env...)
}
err := cmd.Run()
if err != nil {
return portainer.Error(stderr.String())

View File

@ -3,35 +3,22 @@ package handler
import (
"os"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"log"
"net/http"
"path"
"strings"
)
// FileHandler represents an HTTP API handler for managing static files.
type FileHandler struct {
http.Handler
Logger *log.Logger
allowedDirectories map[string]bool
Logger *log.Logger
}
// NewFileHandler returns a new instance of FileHandler.
func NewFileHandler(assetPath string) *FileHandler {
func NewFileHandler(assetPublicPath string) *FileHandler {
h := &FileHandler{
Handler: http.FileServer(http.Dir(assetPath)),
Handler: http.FileServer(http.Dir(assetPublicPath)),
Logger: log.New(os.Stderr, "", log.LstdFlags),
allowedDirectories: map[string]bool{
"/": true,
"/css": true,
"/js": true,
"/images": true,
"/fonts": true,
"/ico": true,
},
}
return h
}
@ -46,17 +33,10 @@ func isHTML(acceptContent []string) bool {
}
func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestDirectory := path.Dir(r.URL.Path)
if !handler.allowedDirectories[requestDirectory] {
httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger)
return
}
if !isHTML(r.Header["Accept"]) {
w.Header().Set("Cache-Control", "max-age=31536000")
} else {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
handler.Handler.ServeHTTP(w, r)
}

View File

@ -84,6 +84,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
resourceControlType = portainer.SecretResourceControl
case "stack":
resourceControlType = portainer.StackResourceControl
case "config":
resourceControlType = portainer.ConfigResourceControl
default:
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
return

View File

@ -5,6 +5,7 @@ import (
"path"
"strconv"
"strings"
"sync"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
@ -22,6 +23,8 @@ import (
// StackHandler represents an HTTP API handler for managing Stack.
type StackHandler struct {
stackCreationMutex *sync.Mutex
stackDeletionMutex *sync.Mutex
*mux.Router
Logger *log.Logger
FileService portainer.FileService
@ -29,17 +32,21 @@ type StackHandler struct {
StackService portainer.StackService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
StackManager portainer.StackManager
}
// NewStackHandler returns a new instance of StackHandler.
func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
h := &StackHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
Router: mux.NewRouter(),
stackCreationMutex: &sync.Mutex{},
stackDeletionMutex: &sync.Mutex{},
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/{endpointId}/stacks",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost)
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost)
h.Handle("/{endpointId}/stacks",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet)
h.Handle("/{endpointId}/stacks/{id}",
@ -55,11 +62,12 @@ func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
type (
postStacksRequest struct {
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
GitRepository string `valid:""`
PathInRepository string `valid:""`
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
GitRepository string `valid:""`
PathInRepository string `valid:""`
Env []portainer.Pair `valid:""`
}
postStacksResponse struct {
ID string `json:"Id"`
@ -68,7 +76,8 @@ type (
StackFileContent string `json:"StackFileContent"`
}
putStackRequest struct {
StackFileContent string `valid:"required"`
StackFileContent string `valid:"required"`
Env []portainer.Pair `valid:""`
}
)
@ -158,6 +167,7 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter,
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
Env: req.Env,
}
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
@ -173,7 +183,31 @@ func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter,
return
}
err = handler.StackManager.Deploy(stack, endpoint)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
dockerhub, err := handler.DockerHubService.DockerHub()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -251,6 +285,7 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
Name: stackName,
SwarmID: swarmID,
EntryPoint: req.PathInRepository,
Env: req.Env,
}
projectPath := handler.FileService.GetStackProjectPath(string(stack.ID))
@ -275,7 +310,31 @@ func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWri
return
}
err = handler.StackManager.Deploy(stack, endpoint)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
dockerhub, err := handler.DockerHubService.DockerHub()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -314,6 +373,13 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
return
}
envParam := r.FormValue("Env")
var env []portainer.Pair
if err = json.Unmarshal([]byte(envParam), &env); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackFile, _, err := r.FormFile("file")
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@ -339,6 +405,7 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
Env: env,
}
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
@ -354,7 +421,31 @@ func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r
return
}
err = handler.StackManager.Deploy(stack, endpoint)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
dockerhub, err := handler.DockerHubService.DockerHub()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -508,6 +599,7 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stack.Env = req.Env
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
if err != nil {
@ -515,7 +607,37 @@ func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Reque
return
}
err = handler.StackManager.Deploy(stack, endpoint)
err = handler.StackService.UpdateStack(stack.ID, stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
dockerhub, err := handler.DockerHubService.DockerHub()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
registries, err := handler.RegistryService.Registries()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -589,11 +711,13 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re
return
}
handler.stackDeletionMutex.Lock()
err = handler.StackManager.Remove(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
handler.stackDeletionMutex.Unlock()
err = handler.StackService.DeleteStack(portainer.StackID(stackID))
if err != nil {
@ -607,3 +731,28 @@ func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Re
return
}
}
func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error {
handler.stackCreationMutex.Lock()
err := handler.StackManager.Login(dockerhub, registries, endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
}
err = handler.StackManager.Logout(endpoint)
if err != nil {
handler.stackCreationMutex.Unlock()
return err
}
handler.stackCreationMutex.Unlock()
return nil
}

View File

@ -43,16 +43,17 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht
}
var templatesURL string
if key == "containers" {
switch key {
case "containers":
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
templatesURL = settings.TemplatesURL
} else if key == "linuxserver.io" {
case "linuxserver.io":
templatesURL = containerTemplatesURLLinuxServerIo
} else {
default:
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}

107
api/http/proxy/configs.go Normal file
View File

@ -0,0 +1,107 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier
ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found")
configIdentifier = "ID"
)
// configListOperation extracts the response as a JSON object, loop through the configs array
// decorate and/or filter the configs based on resource controls before rewriting the response
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ConfigList response is a JSON array
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterConfigList(responseArray, executor.operationContext)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// configInspectOperation extracts the response as a JSON object, verify that the user
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
// and either rewrite an access denied response or a decorated config.
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ConfigInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[configIdentifier] == nil {
return ErrDockerConfigIdentifierNotFound
}
configID := responseObject[configIdentifier].(string)
responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
}
return rewriteResponse(response, responseObject, http.StatusOK)
}
// decorateConfigList loops through all configs and decorates any config with an existing resource control.
// Resource controls checks are based on: resource identifier.
// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedConfigData := make([]interface{}, 0)
for _, config := range configData {
configObject := config.(map[string]interface{})
if configObject[configIdentifier] == nil {
return nil, ErrDockerConfigIdentifierNotFound
}
configID := configObject[configIdentifier].(string)
configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls)
decoratedConfigData = append(decoratedConfigData, configObject)
}
return decoratedConfigData, nil
}
// filterConfigList loops through all configs and filters public configs (no associated resource control)
// as well as authorized configs (access granted to the user based on existing resource control).
// Authorized configs are decorated during the process.
// Resource controls checks are based on: resource identifier.
// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
func filterConfigList(configData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredConfigData := make([]interface{}, 0)
for _, config := range configData {
configObject := config.(map[string]interface{})
if configObject[configIdentifier] == nil {
return nil, ErrDockerConfigIdentifierNotFound
}
configID := configObject[configIdentifier].(string)
configObject, access := applyResourceAccessControl(configObject, configID, context)
if access {
filteredConfigData = append(filteredConfigData, configObject)
}
}
return filteredConfigData, nil
}

View File

@ -41,6 +41,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
path := request.URL.Path
switch {
case strings.HasPrefix(path, "/configs"):
return p.proxyConfigRequest(request)
case strings.HasPrefix(path, "/containers"):
return p.proxyContainerRequest(request)
case strings.HasPrefix(path, "/services"):
@ -62,6 +64,24 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
}
}
func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/configs/create":
return p.executeDockerRequest(request)
case "/configs":
return p.rewriteOperation(request, configListOperation)
default:
// assume /configs/{id}
if request.Method == http.MethodGet {
return p.rewriteOperation(request, configInspectOperation)
}
configID := path.Base(requestPath)
return p.restrictedOperation(request, configID)
}
}
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
switch requestPath := request.URL.Path; requestPath {
case "/containers/create":

View File

@ -61,7 +61,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po
}
// FilterRegistries filters registries based on user role and team memberships.
// Non administrator users only have access to authorized endpoints.
// Non administrator users only have access to authorized registries.
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) {
filteredRegistries := registries

View File

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/http/security"
"net/http"
"path/filepath"
)
// Server implements the portainer.Server interface
@ -42,7 +43,7 @@ func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var fileHandler = handler.NewFileHandler(server.AssetsPath)
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
@ -93,6 +94,8 @@ func (server *Server) Start() error {
stackHandler.ResourceControlService = server.ResourceControlService
stackHandler.StackManager = server.StackManager
stackHandler.GitService = server.GitService
stackHandler.RegistryService = server.RegistryService
stackHandler.DockerHubService = server.DockerHubService
server.Handler = &handler.Handler{
AuthHandler: authHandler,

View File

@ -138,6 +138,7 @@ type (
EntryPoint string `json:"EntryPoint"`
SwarmID string `json:"SwarmId"`
ProjectPath string
Env []Pair `json:"Env"`
}
// RegistryID represents a registry identifier.
@ -379,6 +380,8 @@ type (
// StackManager represents a service to manage stacks.
StackManager interface {
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error
Logout(endpoint *Endpoint) error
Deploy(stack *Stack, endpoint *Endpoint) error
Remove(stack *Stack, endpoint *Endpoint) error
}
@ -386,7 +389,7 @@ type (
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.15.0"
APIVersion = "1.15.1"
// DBVersion is the version number of the Portainer database.
DBVersion = 6
// DefaultTemplatesURL represents the default URL for the templates definitions.
@ -446,4 +449,6 @@ const (
SecretResourceControl
// StackResourceControl represents a resource control associated to a stack composed of Docker services
StackResourceControl
// ConfigResourceControl represents a resource control associated to a Docker config
ConfigResourceControl
)

View File

@ -56,7 +56,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.15.0"
version: "1.15.1"
title: "Portainer API"
contact:
email: "info@portainer.io"
@ -1869,7 +1869,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.15.0"
example: "1.15.1"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"

View File

@ -16,12 +16,16 @@ angular.module('portainer', [
'portainer.services',
'auth',
'dashboard',
'config',
'configs',
'container',
'containerConsole',
'containerLogs',
'containerStats',
'containerInspect',
'serviceLogs',
'containers',
'createConfig',
'createContainer',
'createNetwork',
'createRegistry',

View File

@ -0,0 +1,81 @@
<rd-header>
<rd-header-title title="Config details">
<a data-toggle="tooltip" title="Refresh" ui-sref="config({id: config.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="configs">Configs</a> &gt; <a ui-sref="config({id: config.Id})">{{ config.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Config details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>{{ config.Name }}</td>
</tr>
<tr>
<td>ID</td>
<td>
{{ config.Id }}
<button class="btn btn-xs btn-danger" ng-click="removeConfig(config.Id)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Delete this config</button>
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ config.CreatedAt | getisodate }}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{{ config.UpdatedAt | getisodate }}</td>
</tr>
<tr ng-if="!(config.Labels | emptyobject)">
<td>Labels</td>
<td>
<table class="table table-bordered table-condensed">
<tr ng-repeat="(k, v) in config.Labels">
<td>{{ k }}</td>
<td>{{ v }}</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="config && applicationState.application.authentication"
resource-id="config.Id"
resource-control="config.ResourceControl"
resource-type="'config'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="config">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Config content"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" ng-model="config.Data" class="form-control"></textarea>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,45 @@
angular.module('config', [])
.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService',
function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) {
$scope.removeConfig = function removeConfig(configId) {
$('#loadingViewSpinner').show();
ConfigService.remove(configId)
.then(function success(data) {
Notifications.success('Config successfully removed');
$state.go('configs', {});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove config');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
};
function initEditor() {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('config-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true);
}
});
}
function initView() {
$('#loadingViewSpinner').show();
ConfigService.config($transition$.params().id)
.then(function success(data) {
$scope.config = data;
initEditor();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve config details');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -0,0 +1,81 @@
<rd-header>
<rd-header-title title="Configs list">
<a data-toggle="tooltip" title="Refresh" ui-sref="configs" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Configs</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-file-code-o" title="Configs">
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.config"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add config</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr dir-paginate="config in (state.filteredConfigs = ( configs | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
<td><input type="checkbox" ng-model="config.Checked" ng-change="selectItem(config)"/></td>
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
<td>{{ config.CreatedAt | getisodate }}</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="config.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!configs">
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="configs.length == 0">
<td colspan="4" class="text-center text-muted">No configs available.</td>
</tr>
</tbody>
</table>
<div ng-if="configs" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View File

@ -0,0 +1,76 @@
angular.module('configs', [])
.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination',
function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('configs');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredConfigs, function (config) {
if (config.Checked !== allSelected) {
config.Checked = allSelected;
$scope.selectItem(config);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
$('#loadingViewSpinner').show();
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
$('#loadingViewSpinner').hide();
}
};
angular.forEach($scope.configs, function (config) {
if (config.Checked) {
counter = counter + 1;
ConfigService.remove(config.Id)
.then(function success() {
Notifications.success('Config deleted', config.Id);
var index = $scope.configs.indexOf(config);
$scope.configs.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove config');
})
.finally(function final() {
complete();
});
}
});
};
function initView() {
$('#loadingViewSpinner').show();
ConfigService.configs()
.then(function success(data) {
$scope.configs = data;
})
.catch(function error(err) {
$scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve configs');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -83,6 +83,7 @@
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="inspect({id: container.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i>Inspect</a>
</div>
</td>
</tr>
@ -240,7 +241,7 @@
</div>
</div>
<div class="row" ng-if="container.HostConfig.Binds.length > 0">
<div class="row" ng-if="container.Mounts.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-cubes" title="Volumes"></rd-widget-header>
@ -248,14 +249,15 @@
<table class="table">
<thead>
<tr>
<th>Host</th>
<th>Container</th>
<th>Host/volume</th>
<th>Path in container</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="vol in container.HostConfig.Binds">
<td>{{ vol|key: ':' }}</td>
<td>{{ vol|value: ':' }}</td>
<tr ng-repeat="vol in container.Mounts">
<td ng-if="vol.Type === 'bind'">{{ vol.Source }}</td>
<td ng-if="vol.Type === 'volume'"><a ui-sref="volume({id: vol.Name})">{{ vol.Name }}</a></td>
<td>{{ vol.Destination }}</td>
</tr>
</tbody>
</table>

View File

@ -197,10 +197,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit,
};
$scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
$state.go('actions.create.container', {from: $transition$.params().id}, {reload: true});
});
$state.go('actions.create.container', {from: $transition$.params().id}, {reload: true});
};
$scope.confirmRemove = function () {
@ -264,17 +261,13 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit,
}
$scope.recreate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
ModalService.confirmContainerRecreation(function (result) {
if(!result) { return; }
var pullImage = false;
if (result[0]) {
pullImage = true;
}
recreateContainer(pullImage);
});
ModalService.confirmContainerRecreation(function (result) {
if(!result) { return; }
var pullImage = false;
if (result[0]) {
pullImage = true;
}
recreateContainer(pullImage);
});
};

View File

@ -0,0 +1,24 @@
<rd-header>
<rd-header-title title="Container inspect">
</rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: containerInfo.Id})">{{ containerInfo.Name|trimcontainername }}</a> &gt; Inspect
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-icon-circle" title="Inspect">
<span class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="state.DisplayTextView" uib-btn-radio="false"><i class="fa fa-code space-right" aria-hidden="true"></i>Tree</label>
<label class="btn btn-primary" ng-model="state.DisplayTextView" uib-btn-radio="true"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i>Text</label>
</span>
</rd-widget-header>
<rd-widget-body>
<pre ng-show="state.DisplayTextView">{{ containerInfo|json:4 }}</pre>
<json-tree ng-hide="state.DisplayTextView" object="containerInfo" root-name="containerInfo.Id" start-expanded="true"></json-tree>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,24 @@
angular.module('containerInspect', ['angular-json-tree'])
.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService',
function ($scope, $transition$, Notifications, ContainerService) {
$scope.state = { DisplayTextView: false };
$scope.containerInfo = {};
function initView() {
$('#loadingViewSpinner').show();
ContainerService.inspect($transition$.params().id)
.then(function success(d) {
$scope.containerInfo = d;
})
.catch(function error(e) {
Notifications.error('Failure', e, 'Unable to inspect container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -0,0 +1,102 @@
angular.module('createConfig', [])
.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService',
function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) {
$scope.formValues = {
Name: '',
Labels: [],
AccessControlData: new AccessControlFormData()
};
$scope.state = {
formValidationError: ''
};
$scope.addLabel = function() {
$scope.formValues.Labels.push({ name: '', value: ''});
};
$scope.removeLabel = function(index) {
$scope.formValues.Labels.splice(index, 1);
};
function prepareLabelsConfig(config) {
var labels = {};
$scope.formValues.Labels.forEach(function (label) {
if (label.name && label.value) {
labels[label.name] = label.value;
}
});
config.Labels = labels;
}
function prepareConfigData(config) {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var configData = $scope.editor.getValue();
config.Data = btoa(unescape(encodeURIComponent(configData)));
}
function prepareConfiguration() {
var config = {};
config.Name = $scope.formValues.Name;
prepareConfigData(config);
prepareLabelsConfig(config);
return config;
}
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
$scope.create = function () {
$('#createResourceSpinner').show();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createResourceSpinner').hide();
return;
}
var config = prepareConfiguration();
ConfigService.create(config)
.then(function success(data) {
var configIdentifier = data.ID;
var userId = userDetails.ID;
return ResourceControlService.applyResourceControl('config', configIdentifier, userId, accessControlData, []);
})
.then(function success() {
Notifications.success('Config successfully created');
$state.go('configs', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create config');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function initView() {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('config-editor', false);
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false);
}
});
}
initView();
}]);

View File

@ -0,0 +1,74 @@
<rd-header>
<rd-header-title title="Create config"></rd-header-title>
<rd-header-content>
<a ui-sref="configs">Configs</a> &gt; Add config
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="config_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.Name" id="config_name" placeholder="e.g. myConfig">
</div>
</div>
<!-- !name-input -->
<!-- config-data -->
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" class="form-control"></textarea>
</div>
</div>
<!-- !config-data -->
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels-->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name" ng-click="create()">Create config</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="configs">Cancel</a>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createService', [])
.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService',
function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService',
function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
$scope.formValues = {
Name: '',
@ -28,6 +28,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
UpdateOrder: 'stop-first',
FailureAction: 'pause',
Secrets: [],
Configs: [],
AccessControlData: new AccessControlFormData(),
CpuLimit: 0,
CpuReservation: 0,
@ -71,6 +72,14 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$scope.formValues.Volumes.splice(index, 1);
};
$scope.addConfig = function() {
$scope.formValues.Configs.push({});
};
$scope.removeConfig = function(index) {
$scope.formValues.Configs.splice(index, 1);
};
$scope.addSecret = function() {
$scope.formValues.Secrets.push({});
};
@ -189,10 +198,32 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels);
}
function createMountObjectFromVolume(volumeObject, target, readonly) {
return {
Target: target,
Source: volumeObject.Id,
Type: 'volume',
ReadOnly: readonly,
VolumeOptions: {
Labels: volumeObject.Labels,
DriverConfig: {
Name: volumeObject.Driver,
Options: volumeObject.Options
}
}
};
}
function prepareVolumes(config, input) {
input.Volumes.forEach(function (volume) {
if (volume.Source && volume.Target) {
config.TaskTemplate.ContainerSpec.Mounts.push(volume);
if (volume.Type !== 'volume') {
config.TaskTemplate.ContainerSpec.Mounts.push(volume);
} else {
var volumeObject = volume.Source;
var mount = createMountObjectFromVolume(volumeObject, volume.Target, volume.ReadOnly);
config.TaskTemplate.ContainerSpec.Mounts.push(mount);
}
}
});
}
@ -222,6 +253,20 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences);
}
function prepareConfigConfig(config, input) {
if (input.Configs) {
var configs = [];
angular.forEach(input.Configs, function(config) {
if (config.model) {
var s = ConfigHelper.configConfig(config.model);
s.File.Name = config.FileName || s.File.Name;
configs.push(s);
}
});
config.TaskTemplate.ContainerSpec.Configs = configs;
}
}
function prepareSecretConfig(config, input) {
if (input.Secrets) {
var secrets = [];
@ -294,6 +339,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
prepareVolumes(config, input);
prepareNetworks(config, input);
prepareUpdateConfig(config, input);
prepareConfigConfig(config, input);
prepareSecretConfig(config, input);
preparePlacementConfig(config, input);
prepareResourcesCpuConfig(config, input);
@ -382,8 +428,9 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$q.all({
volumes: VolumeService.volumes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
networks: NetworkService.networks(true, true, false, false),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
configs: apiVersion >= 1.30 ? ConfigService.configs() : [],
nodes: NodeService.nodes(),
settings: SettingsService.publicSettings()
})
@ -391,6 +438,7 @@ function ($q, $scope, $state, $timeout, Service, ServiceHelper, SecretHelper, Se
$scope.availableVolumes = data.volumes;
$scope.availableNetworks = data.networks;
$scope.availableSecrets = data.secrets;
$scope.availableConfigs = data.configs;
var nodes = data.nodes;
initSlidersMaxValuesBasedOnNodeData(nodes);
var settings = data.settings;

View File

@ -133,6 +133,7 @@
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#update-config" data-toggle="tab">Update config</a></li>
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
<li class="interactive"><a data-target="#configs" data-toggle="tab" ng-if="applicationState.endpoint.apiVersion >= 1.30">Configs</a></li>
<li class="interactive"><a data-target="#resources-placement" data-toggle="tab" ng-click="refreshSlider()">Resources & Placement</a></li>
</ul>
<!-- tab-content -->
@ -240,9 +241,8 @@
<!-- volume -->
<div class="input-group input-group-sm col-sm-6" ng-if="volume.Type === 'volume'">
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.Source">
<select class="form-control" ng-model="volume.Source" ng-options="vol.Id|truncate:30 for vol in availableVolumes">
<option selected disabled hidden value="">Select a volume</option>
<option ng-repeat="vol in availableVolumes" ng-value="vol.Id">{{ vol.Id|truncate:30 }}</option>
</select>
</div>
<!-- !volume -->
@ -442,6 +442,9 @@
<!-- tab-secrets -->
<div class="tab-pane" id="secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" ng-include="'app/components/createService/includes/secret.html'"></div>
<!-- !tab-secrets -->
<!-- tab-configs -->
<div class="tab-pane" id="configs" ng-if="applicationState.endpoint.apiVersion >= 1.30" ng-include="'app/components/createService/includes/config.html'"></div>
<!-- !tab-configs -->
<!-- tab-resources-placement -->
<div class="tab-pane" id="resources-placement" ng-include="'app/components/createService/includes/resources-placement.html'"></div>
<!-- !tab-resources-placement -->

View File

@ -0,0 +1,27 @@
<form class="form-horizontal" style="margin-top: 15px;">
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Configs</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addConfig()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add a config
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="config in formValues.Configs" style="margin-top: 2px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">config</span>
<select class="form-control" ng-model="config.model" ng-options="config.Name for config in availableConfigs">
<option value="" selected="selected">Select a config</option>
</select>
</div>
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">Path in container</span>
<input class="form-control" ng-model="config.FileName" placeholder="e.g. /path/in/container" />
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeConfig($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
angular.module('createStack', [])
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) {
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) {
// Store the editor content when switching builder methods
var editorContent = '';
@ -11,6 +11,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
StackFileContent: '# Define or paste the content of your docker-compose file here',
StackFile: null,
RepositoryURL: '',
Env: [],
RepositoryPath: 'docker-compose.yml',
AccessControlData: new AccessControlFormData()
};
@ -20,6 +21,14 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
formValidationError: ''
};
$scope.addEnvironmentVariable = function() {
$scope.formValues.Env.push({ name: '', value: ''});
};
$scope.removeEnvironmentVariable = function(index) {
$scope.formValues.Env.splice(index, 1);
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
@ -34,20 +43,21 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
function createStack(name) {
var method = $scope.state.Method;
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
if (method === 'editor') {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFileContent = $scope.editor.getValue();
return StackService.createStackFromFileContent(name, stackFileContent);
return StackService.createStackFromFileContent(name, stackFileContent, env);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createStackFromFileUpload(name, stackFile);
return StackService.createStackFromFileUpload(name, stackFile, env);
} else if (method === 'repository') {
var gitRepository = $scope.formValues.RepositoryURL;
var pathInRepository = $scope.formValues.RepositoryPath;
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository);
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository, env);
}
}
@ -91,7 +101,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
if (value) {
$scope.editor.setValue(value);
}

View File

@ -131,6 +131,36 @@
</div>
</div>
</div>
<div class="col-sm-12 form-section-title">
Environment
</div>
<!-- environment-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in formValues.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
<!-- !repository -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- actions -->

View File

@ -85,7 +85,7 @@
</div>
<div class="row">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="stacks">
<rd-widget>
<rd-widget-body>
@ -98,7 +98,7 @@
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services">
<rd-widget>
<rd-widget-body>

View File

@ -68,6 +68,7 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
$('#loadingViewSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var endpointRole = $scope.applicationState.endpoint.mode.role;
$q.all([
Container.query({all: 1}).$promise,
@ -75,8 +76,8 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
Volume.query({}).$promise,
Network.query({}).$promise,
SystemService.info(),
endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [],
endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : []
endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [],
endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? StackService.stacks(true) : []
]).then(function (d) {
prepareContainerData(d[0]);
prepareImageData(d[1]);

View File

@ -1,6 +1,6 @@
angular.module('endpoints', [])
.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination',
function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) {
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination',
function ($scope, $state, $filter, EndpointService, EndpointProvider, Notifications, Pagination) {
$scope.state = {
uploadInProgress: false,
selectedItemCount: 0,
@ -44,7 +44,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi
$scope.addEndpoint = function() {
var name = $scope.formValues.Name;
var URL = $scope.formValues.URL;
var URL = $filter('stripprotocol')($scope.formValues.URL);
var PublicURL = $scope.formValues.PublicURL;
if (PublicURL === '') {
PublicURL = URL.split(':')[0];

View File

@ -125,10 +125,7 @@
<td><input type="checkbox" ng-model="image.Checked" ng-change="selectItem(image)" /></td>
<td>
<a class="monospaced" ui-sref="image({id: image.Id})">{{ image.Id|truncate:20}}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag"
ng-if="::image.ContainerCount === 0">
Unused
</span>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::image.ContainerCount === 0">Unused</span>
</td>
<td>
<span class="label label-primary image-tag" ng-repeat="tag in (image|repotags)">{{ tag }}</span>

View File

@ -40,12 +40,14 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai
var containersInNetwork = [];
containers.forEach(function(container) {
var containerInNetwork = network.Containers[container.Id];
containerInNetwork.Id = container.Id;
// Name is not available in Docker 1.9
if (!containerInNetwork.Name) {
containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]);
if (containerInNetwork) {
containerInNetwork.Id = container.Id;
// Name is not available in Docker 1.9
if (!containerInNetwork.Name) {
containerInNetwork.Name = $filter('trimcontainername')(container.Names[0]);
}
containersInNetwork.push(containerInNetwork);
}
containersInNetwork.push(containerInNetwork);
});
$scope.containersInNetwork = containersInNetwork;
}
@ -68,7 +70,7 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai
});
} else {
Container.query({
filters: {network: [$transition$.params().id]}
filters: { network: [$transition$.params().id] }
}, function success(data) {
filterContainersInNetwork(network, data);
$('#loadingViewSpinner').hide();

View File

@ -16,7 +16,7 @@
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.secret">Add secret</a>
<a class="btn btn-primary" type="button" ui-sref="actions.create.secret"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
@ -39,8 +39,8 @@
<th>
<a ng-click="order('CreatedAt')">
Created at
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">

View File

@ -1,6 +1,6 @@
angular.module('secrets', [])
.controller('SecretsController', ['$scope', '$transition$', '$state', 'SecretService', 'Notifications', 'Pagination',
function ($scope, $transition$, $state, SecretService, Notifications, Pagination) {
.controller('SecretsController', ['$scope', '$state', 'SecretService', 'Notifications', 'Pagination',
function ($scope, $state, SecretService, Notifications, Pagination) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('secrets');

View File

@ -0,0 +1,62 @@
<form ng-if="applicationState.endpoint.apiVersion >= 1.30" id="service-configs" ng-submit="updateService(service)">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Configs">
</rd-widget-header>
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px;">
Add a config:
<select class="form-control" ng-options="config.Name for config in configs" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add config
</a>
</div>
<table class="table" style="margin-top: 5px;">
<thead>
<tr>
<th>Name</th>
<th>Path in container</th>
<th>UID</th>
<th>GID</th>
<th>Mode</th>
<th></th>
</tr>
</thead>
<tbody>
<tr ng-repeat="config in service.ServiceConfigs">
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
<td>
<input class="form-control" ng-model="config.FileName" ng-change="updateConfig(service)" placeholder="e.g. /path/in/container" required />
</td>
<td>{{ config.Uid }}</td>
<td>{{ config.Gid }}</td>
<td>{{ config.Mode }}</td>
<td>
<button class="btn btn-xs btn-danger pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i> Remove config
</button>
</td>
</tr>
<tr ng-if="service.ServiceConfigs.length === 0">
<td colspan="6" class="text-center text-muted">No configs associated to this service.</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConfigs'])">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConfigs'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</form>

View File

@ -117,6 +117,7 @@
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
@ -164,6 +165,7 @@
<div id="service-restart-policy" class="padding-top" ng-include="'app/components/service/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/components/service/includes/updateconfig.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/components/service/includes/servicelabels.html'"></div>
<div id="service-configs" class="padding-top" ng-include="'app/components/service/includes/configs.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-tasks" class="padding-top" ng-include="'app/components/service/includes/tasks.html'"></div>
</div>

View File

@ -1,6 +1,6 @@
angular.module('service', [])
.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@ -59,6 +59,21 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
}
};
$scope.addConfig = function addConfig(service, config) {
if (config && service.ServiceConfigs.filter(function(serviceConfig) { return serviceConfig.Id === config.Id;}).length === 0) {
service.ServiceConfigs.push({ Id: config.Id, Name: config.Name, FileName: config.Name, Uid: '0', Gid: '0', Mode: 292 });
updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
}
};
$scope.removeConfig = function removeSecret(service, index) {
var removedElement = service.ServiceConfigs.splice(index, 1);
if (removedElement !== null) {
updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
}
};
$scope.updateConfig = function updateConfig(service) {
updateServiceArray(service, 'ServiceConfigs', service.ServiceConfigs);
};
$scope.addSecret = function addSecret(service, secret) {
if (secret && service.ServiceSecrets.filter(function(serviceSecret) { return serviceSecret.Id === secret.Id;}).length === 0) {
service.ServiceSecrets.push({ Id: secret.Id, Name: secret.Name, FileName: secret.Name, Uid: '0', Gid: '0', Mode: 444 });
@ -193,6 +208,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels);
config.TaskTemplate.ContainerSpec.Image = service.Image;
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : [];
if (service.Mode === 'replicated') {
config.Mode.Replicated.Replicas = service.Replicas;
@ -289,6 +305,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
function translateServiceArrays(service) {
service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : [];
service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : [];
service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env);
service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels);
service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels);
@ -323,12 +340,14 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
return $q.all({
tasks: TaskService.tasks({ service: [service.Name] }),
nodes: NodeService.nodes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
configs: apiVersion >= 1.30 ? ConfigService.configs() : []
});
})
.then(function success(data) {
$scope.tasks = data.tasks;
$scope.nodes = data.nodes;
$scope.configs = data.configs;
$scope.secrets = data.secrets;
// Set max cpu value
@ -350,6 +369,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
})
.catch(function error(err) {
$scope.secrets = [];
$scope.configs = [];
Notifications.error('Failure', err, 'Unable to retrieve service details');
})
.finally(function final() {

View File

@ -97,19 +97,22 @@ function ($q, $scope, $transition$, $state, Service, ServiceService, ServiceHelp
$('#loadServicesSpinner').show();
$q.all({
services: Service.query({}).$promise,
tasks: Task.query({filters: {'desired-state': ['running']}}).$promise,
tasks: Task.query({filters: {'desired-state': ['running','accepted']}}).$promise,
nodes: Node.query({}).$promise
})
.then(function success(data) {
$scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes);
$scope.services = data.services.map(function (service) {
var serviceTasks = data.tasks.filter(function (task) {
var runningTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID && task.Status.State === 'running';
});
var allTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID;
});
var taskNodes = data.nodes.filter(function (node) {
return node.Spec.Availability === 'active' && node.Status.State === 'ready';
});
return new ServiceViewModel(service, serviceTasks, taskNodes);
return new ServiceViewModel(service, runningTasks, allTasks, taskNodes);
});
})
.catch(function error(err) {

View File

@ -40,6 +40,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
if (!$scope.formValues.customTemplates) {
settings.TemplatesURL = DEFAULT_TEMPLATES_URL;
}
settings.DisplayExternalContributors = !$scope.formValues.externalContributions;
settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts;
settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode;

View File

@ -66,7 +66,7 @@
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left">
LDAP URL
LDAP Server
<portainer-tooltip position="bottom" message="URL or IP address of the LDAP server."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">

View File

@ -1,13 +1,14 @@
<!-- Sidebar -->
<div id="sidebar-wrapper">
<div class="sidebar-header">
<a ng-click="toggleSidebar()" class="interactive">
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo">
<img ng-if="!logo" src="images/logo.png" class="img-responsive logo" alt="Portainer">
<span class="menu-icon glyphicon glyphicon-transfer"></span>
</a>
</div>
<div class="sidebar-content">
<ul class="sidebar">
<li class="sidebar-main">
<a ng-click="toggleSidebar()" class="interactive">
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo">
<img ng-if="!logo" src="images/logo.png" class="img-responsive logo" alt="Portainer">
<span class="menu-icon glyphicon glyphicon-transfer"></span>
</a>
</li>
<li class="sidebar-title">
<span>Active endpoint</span>
</li>
@ -43,6 +44,9 @@
<li class="sidebar-list">
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.30 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="configs" ui-sref-active="active">Configs <span class="menu-icon fa fa-file-code-o"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret"></span></a>
</li>
@ -77,11 +81,10 @@
</div>
</li>
</ul>
<div class="sidebar-footer">
<div class="col-sm-12">
<img src="images/logo_small.png" class="img-responsive logo" alt="Portainer">
<span class="version">{{ uiVersion }}</span>
</div>
<div class="sidebar-footer-content">
<img src="images/logo_small.png" class="img-responsive logo" alt="Portainer">
<span class="version">{{ uiVersion }}</span>
</div>
</div>
</div>
<!-- End Sidebar -->

View File

@ -38,6 +38,36 @@
<textarea id="web-editor" class="form-control" ng-model="stackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
<div class="col-sm-12 form-section-title">
Environment
</div>
<!-- environment-variables -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Environment variables</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
</span>
</div>
<!-- environment-variable-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="variable in stack.Env" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !environment-variable-input-list -->
</div>
<!-- !environment-variables -->
<div class="col-sm-12 form-section-title">
Actions
</div>

View File

@ -1,6 +1,6 @@
angular.module('stack', [])
.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications',
function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) {
.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper',
function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper) {
$scope.deployStack = function () {
$('#createResourceSpinner').show();
@ -8,8 +8,9 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFile = $scope.editor.getValue();
var env = FormHelper.removeInvalidEnvVars($scope.stack.Env);
StackService.updateStack($scope.stack.Id, stackFile)
StackService.updateStack($scope.stack.Id, stackFile, env)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
$state.reload();
@ -22,6 +23,14 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService
});
};
$scope.addEnvironmentVariable = function() {
$scope.stack.Env.push({ name: '', value: ''});
};
$scope.removeEnvironmentVariable = function(index) {
$scope.stack.Env.splice(index, 1);
};
function initView() {
$('#loadingViewSpinner').show();
var stackId = $stateParams.id;
@ -48,7 +57,7 @@ function ($q, $scope, $state, $stateParams, $document, StackService, NodeService
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
}
});

View File

@ -224,8 +224,8 @@
<tbody>
<tr dir-paginate="node in (state.filteredNodes = (nodes | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td>
<a ui-sref="node({id: node.Id})" ng-if="isAdmin">{{ node.Hostname }}</a>
<span ng-if="!isAdmin">{{ node.Hostname }}</span>
<a ui-sref="node({id: node.Id})" ng-if="!applicationState.application.authentication || isAdmin">{{ node.Hostname }}</a>
<span ng-if="applicationState.application.authentication && !isAdmin">{{ node.Hostname }}</span>
</td>
<td>{{ node.Role }}</td>
<td>{{ node.CPUs / 1000000000 }}</td>

View File

@ -3,14 +3,79 @@
<a data-toggle="tooltip" title="Refresh" ui-sref="templates" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadTemplatesSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Templates</rd-header-content>
</rd-header>
<div class="row" style="height: 90%">
<div class="row">
<!-- stack-form -->
<div class="col-sm-12" ng-if="state.selectedTemplate && state.filters.Type === 'stack'">
<rd-widget>
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title="state.selectedTemplate.Title">
<div class="pull-right">
<button type="button" class="btn btn-sm btn-primary" ng-click="unselectTemplate()">Hide</button>
</div>
</rd-widget-custom-header>
<rd-widget-body classes="padding">
<div class="col-sm-12" ng-if="state.selectedTemplate">
<form class="form-horizontal">
<!-- description -->
<div ng-if="state.selectedTemplate.Note">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-if="state.selectedTemplate.Note" ng-bind-html="state.selectedTemplate.Note"></div>
</div>
</div>
</div>
<!-- !description -->
<div class="col-sm-12 form-section-title">
Configuration
</div>
<!-- name-input -->
<div class="form-group">
<label for="container_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" name="container_name" class="form-control" ng-model="formValues.name" placeholder="e.g. myStack" required>
</div>
</div>
<!-- !name-input -->
<!-- env -->
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="var.label && !var.set" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip></label>
<div class="col-sm-10">
<!-- <input ng-if="!var.values && (!var.type || !var.type === 'container')" type="text" class="form-control" ng-model="var.value" id="field_{{ $index }}"> -->
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}">
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select>
</div>
</div>
<!-- !env -->
<!-- access-control -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.name" ng-click="createTemplate()">Create</button>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
<!-- !stack-form -->
<!-- container-form -->
<div class="col-sm-12" ng-if="state.selectedTemplate && state.filters.Type === 'container'">
<rd-widget>
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title="state.selectedTemplate.Image">
<div class="pull-right">
@ -195,20 +260,40 @@
</div>
</div>
<!-- !volume-mapping -->
<!-- extra-host -->
<div class="form-group" >
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Hosts file entries</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addExtraHost()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add additional entry
</span>
</div>
<!-- extra-host-input-list -->
<div class="col-sm-12">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="(idx, host) in state.selectedTemplate.Hosts track by $index" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="state.selectedTemplate.Hosts[idx]" placeholder="e.g. host:IP">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeExtraHost($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
<!-- !extra-host -->
</div>
<!-- !advanced-options -->
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="small text-muted" style="margin-left: 10px" ng-if="globalNetworkCount === 0 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM' && !state.formValidationError">
When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the <a ui-sref="networks">networks view</a> to create one.
</span>
<span ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && !state.formValidationError" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">App templates cannot be deployed as Swarm Mode services for the moment. You can still use them to quickly deploy containers on the Docker host.</span>
</span>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
@ -218,7 +303,11 @@
</rd-widget-body>
</rd-widget>
</div>
<!-- container-form -->
</div>
<div class="row">
<div class="col-sm-12" style="height: 100%">
<rd-template-widget>
<rd-widget-header icon="fa-rocket" title="Templates">
@ -249,53 +338,92 @@
</div>
</rd-widget-taskbar>
<rd-widget-body classes="padding template-widget-body">
<div class="template-list">
<!-- template -->
<div ng-repeat="tpl in templates | filter:state.filters:true" class="template-container" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index, $index)">
<div class="template-main">
<!-- template-image -->
<span class="">
<img class="template-logo" ng-src="{{ tpl.Logo }}" />
</span>
<!-- !template-image -->
<!-- template-details -->
<span class="col-sm-12">
<!-- template-line1 -->
<div class="template-line">
<span class="template-title">
{{ tpl.Title }}
</span>
<span>
<i class="fa fa-windows" aria-hidden="true" ng-if="tpl.Platform === 'windows'"></i>
<i class="fa fa-linux" aria-hidden="true" ng-if="tpl.Platform === 'linux'"></i>
<!-- Arch / Platform -->
</span>
</div>
<!-- !template-line1 -->
<!-- template-line2 -->
<div class="template-line">
<span class="template-description">
{{ tpl.Description }}
</span>
<span class="small text-muted" ng-if="tpl.Categories.length > 0">
{{ tpl.Categories.join(', ') }}
</span>
</div>
<!-- !template-line2 -->
</span>
<!-- !template-details -->
<form class="form-horizontal">
<div ng-if="templatesKey !== 'linuxserver.io' && state.showDeploymentSelector">
<div class="col-sm-12 form-section-title">
Deployment method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div ng-click="updateCategories(templates, state.filters.Type)">
<input type="radio" id="registry_quay" ng-model="state.filters.Type" value="stack">
<label for="registry_quay">
<div class="boxselector_header">
<i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i>
Stack
</div>
<p>Multi-containers deployment</p>
</label>
</div>
<div ng-click="updateCategories(templates, state.filters.Type)">
<input type="radio" id="registry_custom" ng-model="state.filters.Type" value="container">
<label for="registry_custom">
<div class="boxselector_header">
<i class="fa fa-server" aria-hidden="true" style="margin-right: 2px;"></i>
Container
</div>
<p>Single container deployment</p>
</label>
</div>
</div>
</div>
<!-- !template -->
</div>
<div ng-if="!templates" class="text-center text-muted">
Loading...
<div ng-if="templatesKey !== 'linuxserver.io' && state.showDeploymentSelector">
<div class="col-sm-12 form-section-title">
Templates
</div>
<div class="form-group"></div>
</div>
<div ng-if="(templates | filter:state.filters:true).length == 0" class="text-center text-muted">
No templates available.
<div class="template-list">
<!-- template -->
<div ng-repeat="tpl in templates | filter:state.filters:true" class="template-container" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index, $index)">
<div class="template-main">
<!-- template-image -->
<span class="">
<img class="template-logo" ng-src="{{ tpl.Logo }}" />
</span>
<!-- !template-image -->
<!-- template-details -->
<span class="col-sm-12">
<!-- template-line1 -->
<div class="template-line">
<span class="template-title">
{{ tpl.Title }}
</span>
<span>
<i class="fa fa-windows" aria-hidden="true" ng-if="tpl.Platform === 'windows'"></i>
<i class="fa fa-linux" aria-hidden="true" ng-if="tpl.Platform === 'linux'"></i>
<!-- Arch / Platform -->
</span>
</div>
<!-- !template-line1 -->
<!-- template-line2 -->
<div class="template-line">
<span class="template-description">
{{ tpl.Description }}
</span>
<span class="small text-muted" ng-if="tpl.Categories.length > 0">
{{ tpl.Categories.join(', ') }}
</span>
</div>
<!-- !template-line2 -->
</span>
<!-- !template-details -->
</div>
<!-- !template -->
</div>
<div ng-if="!templates" class="text-center text-muted">
Loading...
</div>
<div ng-if="(templates | filter:state.filters:true).length == 0" class="text-center text-muted">
No templates available.
</div>
</div>
</div>
</form>
</rd-widget-body>
</rd-template-widget>
</div>
</div>

View File

@ -1,14 +1,16 @@
angular.module('templates', [])
.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService',
function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator, SettingsService) {
.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService',
function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator, SettingsService, StackService) {
$scope.state = {
selectedTemplate: null,
showAdvancedOptions: false,
hideDescriptions: $transition$.params().hide_descriptions,
formValidationError: '',
showDeploymentSelector: false,
filters: {
Categories: '!',
Platform: '!'
Platform: '!',
Type: 'container'
}
};
@ -34,6 +36,14 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
$scope.state.selectedTemplate.Ports.splice(index, 1);
};
$scope.addExtraHost = function() {
$scope.state.selectedTemplate.Hosts.push('');
};
$scope.removeExtraHost = function(index) {
$scope.state.selectedTemplate.Hosts.splice(index, 1);
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
@ -46,19 +56,7 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
return true;
}
$scope.createTemplate = function() {
$('#createContainerSpinner').show();
var userDetails = Authentication.getUserDetails();
var accessControlData = $scope.formValues.AccessControlData;
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
var template = $scope.state.selectedTemplate;
function createContainerFromTemplate(template, userId, accessControlData) {
var templateConfiguration = createTemplateConfiguration(template);
var generatedVolumeCount = TemplateHelper.determineRequiredGeneratedVolumeCount(template.Volumes);
var generatedVolumeIds = [];
@ -77,7 +75,6 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
})
.then(function success(data) {
var containerIdentifier = data.Id;
var userId = userDetails.ID;
return ResourceControlService.applyResourceControl('container', containerIdentifier, userId, accessControlData, generatedVolumeIds);
})
.then(function success() {
@ -88,8 +85,59 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
Notifications.error('Failure', err, err.msg);
})
.finally(function final() {
$('#createContainerSpinner').hide();
$('#createResourceSpinner').hide();
});
}
function createStackFromTemplate(template, userId, accessControlData) {
var stackName = $scope.formValues.name;
for (var i = 0; i < template.Env.length; i++) {
var envvar = template.Env[i];
if (envvar.set) {
envvar.value = envvar.set;
}
}
StackService.createStackFromGitRepository(stackName, template.Repository.url, template.Repository.stackfile, template.Env)
.then(function success() {
Notifications.success('Stack successfully created');
})
.catch(function error(err) {
Notifications.warning('Deployment error', err.err.data.err);
})
.then(function success(data) {
return ResourceControlService.applyResourceControl('stack', stackName, userId, accessControlData, []);
})
.then(function success() {
$state.go('stacks', {}, {reload: true});
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
}
$scope.createTemplate = function() {
$('#createResourceSpinner').show();
var userDetails = Authentication.getUserDetails();
var userId = userDetails.ID;
var accessControlData = $scope.formValues.AccessControlData;
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createResourceSpinner').hide();
return;
}
var template = $scope.state.selectedTemplate;
var templatesKey = $scope.templatesKey;
if (template.Type === 'stack') {
createStackFromTemplate(template, userId, accessControlData);
} else {
createContainerFromTemplate(template, userId, accessControlData);
}
};
$scope.unselectTemplate = function() {
@ -144,11 +192,22 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
return containerMapping;
}
function initTemplates() {
var templatesKey = $transition$.params().key;
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
$scope.updateCategories = function(templates, type) {
$scope.state.filters.Categories = '!';
updateCategories(templates, type);
};
function updateCategories(templates, type) {
var availableCategories = [];
angular.forEach(templates, function(template) {
if (template.Type === type) {
availableCategories = availableCategories.concat(template.Categories);
}
});
$scope.availableCategories = _.sortBy(_.uniq(availableCategories));
}
function initTemplates(templatesKey, type, provider, apiVersion) {
$q.all({
templates: TemplateService.getTemplates(templatesKey),
containers: ContainerService.containers(0),
@ -161,12 +220,9 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
settings: SettingsService.publicSettings()
})
.then(function success(data) {
$scope.templates = data.templates;
var availableCategories = [];
angular.forEach($scope.templates, function(template) {
availableCategories = availableCategories.concat(template.Categories);
});
$scope.availableCategories = _.sortBy(_.uniq(availableCategories));
var templates = data.templates;
updateCategories(templates, type);
$scope.templates = templates;
$scope.runningContainers = data.containers;
$scope.availableVolumes = data.volumes.Volumes;
var networks = data.networks;
@ -174,17 +230,33 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
$scope.globalNetworkCount = networks.length;
var settings = data.settings;
$scope.allowBindMounts = settings.AllowBindMountsForRegularUsers;
var userDetails = Authentication.getUserDetails();
$scope.isAdmin = userDetails.role === 1 ? true : false;
})
.catch(function error(err) {
$scope.templates = [];
Notifications.error('Failure', err, 'An error occured during apps initialization.');
})
.finally(function final(){
$('#loadTemplatesSpinner').hide();
$('#loadingViewSpinner').hide();
});
}
initTemplates();
function initView() {
var templatesKey = $transition$.params().key;
$scope.templatesKey = templatesKey;
var userDetails = Authentication.getUserDetails();
$scope.isAdmin = userDetails.role === 1 ? true : false;
var endpointMode = $scope.applicationState.endpoint.mode;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25) {
$scope.state.filters.Type = 'stack';
$scope.state.showDeploymentSelector = true;
}
initTemplates(templatesKey, $scope.state.filters.Type, endpointMode.provider, apiVersion);
}
initView();
}]);

View File

@ -73,3 +73,26 @@
</rd-widget>
</div>
</div>
<div class="row" ng-if="containersUsingVolume.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title="Containers using volume"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<th>Container Name</th>
<th>Mounted At</th>
<th>Read-only</th>
</thead>
<tbody>
<tr ng-repeat="container in containersUsingVolume">
<td><a ui-sref="container({id: container.Id})">{{ container | containername }}</a></td>
<td>{{ container.volumeData.Destination }}</td>
<td>{{ !container.volumeData.RW }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -1,6 +1,6 @@
angular.module('volume', [])
.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'Notifications',
function ($scope, $state, $transition$, VolumeService, Notifications) {
.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications',
function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) {
$scope.removeVolume = function removeVolume() {
$('#loadingViewSpinner').show();
@ -17,12 +17,27 @@ function ($scope, $state, $transition$, VolumeService, Notifications) {
});
};
function getVolumeDataFromContainer(container, volumeId) {
return container.Mounts.find(function(volume) {
return volume.Name === volumeId;
});
}
function initView() {
$('#loadingViewSpinner').show();
VolumeService.volume($transition$.params().id)
.then(function success(data) {
var volume = data;
$scope.volume = volume;
var containerFilter = { volume: [volume.Id] };
return ContainerService.containers(1, containerFilter);
})
.then(function success(data) {
var containers = data.map(function(container) {
container.volumeData = getVolumeDataFromContainer(container, $scope.volume.Id);
return container;
});
$scope.containersUsingVolume = containers;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve volume details');

View File

@ -0,0 +1,34 @@
angular.module('portainer.helpers')
.factory('ConfigHelper', [function ConfigHelperFactory() {
'use strict';
return {
flattenConfig: function(config) {
if (config) {
return {
Id: config.ConfigID,
Name: config.ConfigName,
FileName: config.File.Name,
Uid: config.File.UID,
Gid: config.File.GID,
Mode: config.File.Mode
};
}
return {};
},
configConfig: function(config) {
if (config) {
return {
ConfigID: config.Id,
ConfigName: config.Name,
File: {
Name: config.FileName || config.Name,
UID: config.Uid || '0',
GID: config.Gid || '0',
Mode: config.Mode || 292
}
};
}
return {};
}
};
}]);

18
app/helpers/formHelper.js Normal file
View File

@ -0,0 +1,18 @@
angular.module('portainer.helpers')
.factory('FormHelper', [function FormHelperFactory() {
'use strict';
var helper = {};
helper.removeInvalidEnvVars = function(env) {
for (var i = env.length - 1; i >= 0; i--) {
var envvar = env[i];
if (!envvar.value || !envvar.name) {
env.splice(i, 1);
}
}
return env;
};
return helper;
}]);

View File

@ -22,9 +22,9 @@ angular.module('portainer.helpers')
SecretName: secret.Name,
File: {
Name: secret.FileName,
UID: '0',
GID: '0',
Mode: 444
UID: secret.Uid || '0',
GID: secret.Gid || '0',
Mode: secret.Mode || 444
}
};
}

View File

@ -15,7 +15,8 @@ angular.module('portainer.helpers')
},
PortBindings: {},
Binds: [],
Privileged: false
Privileged: false,
ExtraHosts: []
},
Volumes: {}
};

View File

@ -2,6 +2,7 @@ function StackViewModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Checked = false;
this.Env = data.Env;
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}

View File

@ -0,0 +1,11 @@
function StackTemplateViewModel(data) {
this.Type = data.type;
this.Title = data.title;
this.Description = data.description;
this.Note = data.note;
this.Categories = data.categories ? data.categories : [];
this.Platform = data.platform ? data.platform : 'undefined';
this.Logo = data.logo;
this.Repository = data.repository;
this.Env = data.env ? data.env : [];
}

View File

@ -1,4 +1,5 @@
function TemplateViewModel(data) {
this.Type = data.type;
this.Title = data.title;
this.Description = data.description;
this.Note = data.note;
@ -42,4 +43,5 @@ function TemplateViewModel(data) {
};
});
}
this.Hosts = data.hosts ? data.hosts : [];
}

View File

@ -0,0 +1,15 @@
function ConfigViewModel(data) {
this.Id = data.ID;
this.CreatedAt = data.CreatedAt;
this.UpdatedAt = data.UpdatedAt;
this.Version = data.Version.Index;
this.Name = data.Spec.Name;
this.Labels = data.Spec.Labels;
this.Data = atob(data.Spec.Data);
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
}
}

View File

@ -7,6 +7,7 @@ function ContainerViewModel(data) {
if (data.NetworkSettings && !_.isEmpty(data.NetworkSettings.Networks)) {
this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress;
}
this.NetworkSettings = data.NetworkSettings;
this.Image = data.Image;
this.ImageID = data.ImageID;
this.Command = data.Command;

View File

@ -9,6 +9,7 @@ function ContainerDetailsViewModel(data) {
this.Image = data.Image;
this.Config = data.Config;
this.HostConfig = data.HostConfig;
this.Mounts = data.Mounts;
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);

View File

@ -1,4 +1,4 @@
function ServiceViewModel(data, runningTasks, nodes) {
function ServiceViewModel(data, runningTasks, allTasks, nodes) {
this.Model = data;
this.Id = data.ID;
this.Tasks = [];
@ -12,8 +12,8 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.Replicas = data.Spec.Mode.Replicated.Replicas;
} else {
this.Mode = 'global';
if (nodes) {
this.Replicas = nodes.length;
if (allTasks) {
this.Replicas = allTasks.length;
}
}
if (runningTasks) {
@ -69,6 +69,7 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.Hosts = containerSpec.Hosts;
this.DNSConfig = containerSpec.DNSConfig;
this.Secrets = containerSpec.Secrets;
this.Configs = containerSpec.Configs;
}
if (data.Endpoint) {
this.Ports = data.Endpoint.Ports;

12
app/rest/docker/config.js Normal file
View File

@ -0,0 +1,12 @@
angular.module('portainer.rest')
.factory('Config', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ConfigFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/configs/:id/:action', {
endpointId: EndpointProvider.endpointID
}, {
get: { method: 'GET', params: { id: '@id' } },
query: { method: 'GET', isArray: true },
create: { method: 'POST', params: { action: 'create' } },
remove: { method: 'DELETE', params: { id: '@id' } }
});
}]);

View File

@ -40,6 +40,9 @@ angular.module('portainer.rest')
exec: {
method: 'POST', params: {id: '@id', action: 'exec'},
transformResponse: genericHandler
},
inspect: {
method: 'GET', params: { id: '@id', action: 'json' }
}
});
}]);

View File

@ -26,6 +26,32 @@ function configureRoutes($stateProvider) {
requiresLogin: false
}
})
.state('configs', {
url: '^/configs/',
views: {
'content@': {
templateUrl: 'app/components/configs/configs.html',
controller: 'ConfigsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('config', {
url: '^/config/:id/',
views: {
'content@': {
templateUrl: 'app/components/config/config.html',
controller: 'ConfigController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('containers', {
parent: 'root',
url: '/containers/',
@ -105,6 +131,19 @@ function configureRoutes($stateProvider) {
}
}
})
.state('inspect', {
url: '^/containers/:id/inspect',
views: {
'content@': {
templateUrl: 'app/components/containerInspect/containerInspect.html',
controller: 'ContainerInspectController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('dashboard', {
parent: 'root',
url: '/dashboard',
@ -143,6 +182,19 @@ function configureRoutes($stateProvider) {
}
}
})
.state('actions.create.config', {
url: '/config',
views: {
'content@': {
templateUrl: 'app/components/createConfig/createconfig.html',
controller: 'CreateConfigController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('actions.create.container', {
url: '/container/:from',
views: {

View File

@ -100,13 +100,19 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
return deferred.promise;
};
service.createStackFromFileContent = function(name, stackFileContent) {
service.createStackFromFileContent = function(name, stackFileContent, env) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$promise;
var payload = {
Name: name,
SwarmID: swarm.Id,
StackFileContent: stackFileContent,
Env: env
};
return Stack.create({ method: 'string' }, payload).$promise;
})
.then(function success(data) {
deferred.resolve(data);
@ -118,13 +124,20 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
return deferred.promise;
};
service.createStackFromGitRepository = function(name, gitRepository, pathInRepository) {
service.createStackFromGitRepository = function(name, gitRepository, pathInRepository, env) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$promise;
var payload = {
Name: name,
SwarmID: swarm.Id,
GitRepository: gitRepository,
PathInRepository: pathInRepository,
Env: env
};
return Stack.create({ method: 'repository' }, payload).$promise;
})
.then(function success(data) {
deferred.resolve(data);
@ -136,13 +149,13 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
return deferred.promise;
};
service.createStackFromFileUpload = function(name, stackFile) {
service.createStackFromFileUpload = function(name, stackFile, env) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return FileUploadService.createStack(name, swarm.Id, stackFile);
return FileUploadService.createStack(name, swarm.Id, stackFile, env);
})
.then(function success(data) {
deferred.resolve(data.data);
@ -154,8 +167,8 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic
return deferred.promise;
};
service.updateStack = function(id, stackFile) {
return Stack.update({ id: id, StackFileContent: stackFile }).$promise;
service.updateStack = function(id, stackFile, env) {
return Stack.update({ id: id, StackFileContent: stackFile, Env: env }).$promise;
};
return service;

View File

@ -2,8 +2,11 @@ angular.module('portainer.services')
.factory('CodeMirrorService', function CodeMirrorService() {
'use strict';
var codeMirrorOptions = {
lineNumbers: true,
var codeMirrorGenericOptions = {
lineNumbers: true
};
var codeMirrorYAMLOptions = {
mode: 'text/x-yaml',
gutters: ['CodeMirror-lint-markers'],
lint: true
@ -11,8 +14,18 @@ angular.module('portainer.services')
var service = {};
service.applyCodeMirrorOnElement = function(element) {
var cm = CodeMirror.fromTextArea(element, codeMirrorOptions);
service.applyCodeMirrorOnElement = function(element, yamlLint, readOnly) {
var options = codeMirrorGenericOptions;
if (yamlLint) {
options = codeMirrorYAMLOptions;
}
if (readOnly) {
options.readOnly = true;
}
var cm = CodeMirror.fromTextArea(element, options);
cm.setSize('100%', 500);
return cm;
};

View File

@ -0,0 +1,61 @@
angular.module('portainer.services')
.factory('ConfigService', ['$q', 'Config', function ConfigServiceFactory($q, Config) {
'use strict';
var service = {};
service.config = function(configId) {
var deferred = $q.defer();
Config.get({id: configId}).$promise
.then(function success(data) {
var config = new ConfigViewModel(data);
deferred.resolve(config);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve config details', err: err });
});
return deferred.promise;
};
service.configs = function() {
var deferred = $q.defer();
Config.query({}).$promise
.then(function success(data) {
var configs = data.map(function (item) {
return new ConfigViewModel(item);
});
deferred.resolve(configs);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve configs', err: err });
});
return deferred.promise;
};
service.remove = function(configId) {
var deferred = $q.defer();
Config.remove({ id: configId }).$promise
.then(function success(data) {
if (data.message) {
deferred.reject({ msg: data.message });
} else {
deferred.resolve();
}
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove config', err: err });
});
return deferred.promise;
};
service.create = function(config) {
return Config.create(config).$promise;
};
return service;
}]);

View File

@ -20,8 +20,7 @@ angular.module('portainer.services')
service.containers = function(all, filters) {
var deferred = $q.defer();
Container.query({ all: all, filters: filters ? filters : {} }).$promise
Container.query({ all : all, filters: filters }).$promise
.then(function success(data) {
var containers = data.map(function (item) {
return new ContainerViewModel(item);
@ -140,18 +139,11 @@ angular.module('portainer.services')
};
service.containerTop = function(id) {
var deferred = $q.defer();
return Container.top({id: id}).$promise;
};
Container.top({id: id}).$promise
.then(function success(data) {
var containerTop = data;
deferred.resolve(containerTop);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
service.inspect = function(id) {
return Container.inspect({id: id}).$promise;
};
return service;

View File

@ -8,9 +8,9 @@ angular.module('portainer.services')
return Upload.upload({ url: url, data: { file: file }});
}
service.createStack = function(stackName, swarmId, file) {
service.createStack = function(stackName, swarmId, file, env) {
var endpointID = EndpointProvider.endpointID();
return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } });
return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId, Env: Upload.json(env) } });
};
service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) {

View File

@ -9,7 +9,9 @@ angular.module('portainer.services')
.then(function success(data) {
var templates = data.map(function (tpl, idx) {
var template;
if (key === 'linuxserver.io') {
if (tpl.type === 'stack') {
template = new StackTemplateViewModel(tpl);
} else if (tpl.type === 'container' && key === 'linuxserver.io') {
template = new TemplateLSIOViewModel(tpl);
} else {
template = new TemplateViewModel(tpl);
@ -40,6 +42,7 @@ angular.module('portainer.services')
configuration.HostConfig.NetworkMode = network.Name;
configuration.HostConfig.Privileged = template.Privileged;
configuration.HostConfig.RestartPolicy = { Name: template.RestartPolicy };
configuration.HostConfig.ExtraHosts = template.Hosts ? template.Hosts : [];
configuration.name = containerName;
configuration.Hostname = containerName;
configuration.Image = template.Image;

View File

@ -7,10 +7,6 @@ html, body, #content-wrapper, .page-content, #view {
white-space: normal !important;
}
.btn-group button {
margin: 3px;
}
.messages {
max-height: 50px;
overflow-x: hidden;
@ -282,13 +278,19 @@ a[ng-click]{
}
ul.sidebar {
bottom: 40px;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
ul.sidebar .sidebar-title {
height: auto;
}
ul.sidebar .sidebar-list a {
font-size: 14px;
}
ul.sidebar .sidebar-list a.active {
color: #fff;
text-indent: 22px;
@ -296,7 +298,32 @@ ul.sidebar .sidebar-list a.active {
background: #2d3e63;
}
.sidebar-footer .logo {
.sidebar-header {
height: 60px;
list-style: none;
text-indent: 20px;
font-size: 18px;
background: #2d3e63;
}
.sidebar-header a { color: #fff; }
.sidebar-header a:hover {text-decoration: none; }
.sidebar-header .menu-icon {
float: right;
padding-right: 28px;
line-height: 60px;
}
#page-wrapper:not(.open) .sidebar-footer-content {
display: none;
}
.sidebar-footer-content {
text-align: center;
}
.sidebar-footer-content .logo {
width: 100%;
max-width: 100px;
height: 100%;
@ -304,12 +331,26 @@ ul.sidebar .sidebar-list a.active {
margin: 2px 0 2px 20px;
}
.sidebar-footer .version {
.sidebar-footer-content .version {
font-size: 11px;
margin: 11px 20px 0 7px;
color: #fff;
}
#sidebar-wrapper {
display: flex;
flex-flow: column;
}
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
#image-layers .btn{
padding: 0;
}
@ -331,6 +372,46 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
background: #2d3e63;
}
@media (max-height: 683px) {
ul.sidebar .sidebar-title {
line-height: 28px;
}
ul.sidebar .sidebar-title .form-control {
height: 28px;
padding: 4px 8px;
}
ul.sidebar .sidebar-list {
height: 28px;
}
ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a {
font-size: 12px;
line-height: 28px;
}
ul.sidebar .sidebar-list .menu-icon {
line-height: 28px;
}
}
@media(min-height: 684px) and (max-height: 850px) {
ul.sidebar .sidebar-title {
line-height: 30px;
}
ul.sidebar .sidebar-title .form-control {
height: 30px;
padding: 5px 10px;
}
ul.sidebar .sidebar-list {
height: 30px;
}
ul.sidebar .sidebar-list a, ul.sidebar .sidebar-list .sidebar-sublist a {
font-size: 12px;
line-height: 30px;
}
ul.sidebar .sidebar-list .menu-icon {
line-height: 30px;
}
}
@media(min-width: 768px) and (max-width: 992px) {
.margin-sm-top {
margin-top: 5px;
@ -615,3 +696,20 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
font-family: monospace;
font-weight: 600;
}
/* json-tree */
json-tree {
font-size: 13px;
color: #30426a;
}
json-tree .key {
color: #738bc0;
padding-right: 5px;
}
json-tree .branch-preview {
font-style: normal;
font-size: 11px;
opacity: .5;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1006 B

View File

@ -2,7 +2,7 @@
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/ico/mstile-150x150.png"/>
<square150x150logo src="ico/mstile-150x150.png"/>
<TileColor>#2d89ef</TileColor>
</tile>
</msapplication>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 B

After

Width:  |  Height:  |  Size: 358 B

View File

@ -2,12 +2,12 @@
"name": "Portainer",
"icons": [
{
"src": "/ico/android-chrome-192x192.png",
"src": "ico/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/ico/android-chrome-256x256.png",
"src": "ico/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,6 +1,6 @@
{
"name": "portainer",
"version": "1.15.0",
"version": "1.15.1",
"homepage": "https://github.com/portainer/portainer",
"authors": [
"Anthony Lapenna <anthony.lapenna at gmail dot com>"
@ -34,6 +34,7 @@
"angular-utils-pagination": "~0.11.1",
"angular-local-storage": "~0.5.2",
"angular-jwt": "~0.1.8",
"angular-json-tree": "1.0.1",
"angular-google-analytics": "~1.1.9",
"bootstrap": "~3.3.6",
"filesize": "~3.3.0",

View File

@ -42,7 +42,7 @@ else
if [ `echo "$@" | cut -c1-4` == 'echo' ]; then
bash -c "$@";
else
build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64'
build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le linux-s390x darwin-amd64 windows-amd64'
exit 0
fi
fi

View File

@ -18,9 +18,17 @@ steps:
- grunt build-webapp
- mv api/cmd/portainer/portainer dist/
download_docker_binary:
image: busybox
working_directory: ${{build_frontend}}
commands:
- wget -O /tmp/docker-binaries.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${{DOCKER_VERSION}}.tgz
- tar -xf /tmp/docker-binaries.tgz -C /tmp
- mv /tmp/docker/docker dist/
build_image:
type: build
working_directory: ${{build_frontend}}
working_directory: ${{download_docker_binary}}
dockerfile: ./build/linux/Dockerfile
image_name: portainer/portainer
tag: ${{CF_BRANCH}}

View File

@ -0,0 +1,17 @@
[Unit]
Description=Portainer.io management ui
After=docker.service
Wants=docker.service
Wants=docker-latest.service
[Service]
Type=simple
Restart=always
RestartSec=3
Environment=ASSETS=/usr/share/portainer
Environment=DBFILES=/var/lib/portainer
EnvironmentFile=-/etc/sysconfig/%p
ExecStart=/usr/sbin/portainer -a $ASSETS -d $DBFILES
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,96 @@
Name: portainer
Version: 1.15.1
Release: 0
License: Zlib
Summary: A lightweight docker management UI
Url: https://portainer.io
Group: BLAH
Source0: https://github.com/portainer/portainer/releases/download/%{version}/portainer-%{version}-linux-amd64.tar.gz
Source1: portainer.service
BuildRoot: %{_tmppath}/%{name}-%{version}-build
%if 0%{?suse_version}
BuildRequires: help2man
%endif
Requires: docker
%{?systemd_requires}
BuildRequires: systemd
%description
Portainer is a lightweight management UI which allows you to easily manage
your different Docker environments (Docker hosts or Swarm clusters).
Portainer is meant to be as simple to deploy as it is to use.
It consists of a single container that can run on any Docker engine
(can be deployed as Linux container or a Windows native container).
Portainer allows you to manage your Docker containers, images, volumes,
networks and more ! It is compatible with the standalone Docker engine and with Docker Swarm mode.
%prep
%setup -qn portainer
%build
%if 0%{?suse_version}
help2man -N --no-discard-stderr ./portainer > portainer.1
%endif
%install
# Create directory structure
install -D -m 0755 portainer %{buildroot}%{_sbindir}/portainer
install -d -m 0755 %{buildroot}%{_datadir}/portainer
install -d -m 0755 %{buildroot}%{_localstatedir}/lib/portainer
install -D -m 0644 %{S:1} %{buildroot}%{_unitdir}/portainer.service
%if 0%{?suse_version}
install -D -m 0644 portainer.1 %{buildroot}%{_mandir}/man1/portainer.1
( cd %{buildroot}%{_sbindir} ; ln -s service rcportainer )
%endif
# populate
# don't install docker binary with package use system wide installed one
for src in css fonts ico images index.html js;do
cp -a $src %{buildroot}%{_datadir}/portainer/
done
%pre
%if 0%{?suse_version}
%service_add_pre portainer.service
#%%else # this does not work on rhel 7?
#%%systemd_pre portainer.service
true
%endif
%preun
%if 0%{?suse_version}
%service_del_preun portainer.service
%else
%systemd_preun portainer.service
%endif
%post
%if 0%{?suse_version}
%service_add_post portainer.service
%else
%systemd_post portainer.service
%endif
%postun
%if 0%{?suse_version}
%service_del_postun portainer.service
%else
%systemd_postun_with_restart portainer.service
%endif
%files
%defattr(-,root,root)
%{_sbindir}/portainer
%dir %{_datadir}/portainer
%{_datadir}/portainer/css
%{_datadir}/portainer/fonts
%{_datadir}/portainer/ico
%{_datadir}/portainer/images
%{_datadir}/portainer/index.html
%{_datadir}/portainer/js
%dir %{_localstatedir}/lib/portainer/
%{_unitdir}/portainer.service
%if 0%{?suse_version}
%{_mandir}/man1/portainer.1*
%{_sbindir}/rcportainer
%endif

View File

@ -1,11 +1,15 @@
var autoprefixer = require('autoprefixer');
var cssnano = require('cssnano');
var loadGruntTasks = require('load-grunt-tasks');
var os = require('os');
var arch = os.arch();
if ( arch === 'x64' ) arch = 'amd64';
module.exports = function (grunt) {
loadGruntTasks(grunt);
grunt.loadNpmTasks('gruntify-eslint');
loadGruntTasks(grunt, {
pattern: ['grunt-*', 'gruntify-*']
});
grunt.registerTask('default', ['eslint', 'build']);
grunt.registerTask('before-copy', [
@ -33,8 +37,8 @@ module.exports = function (grunt) {
grunt.registerTask('build', [
'config:dev',
'clean:app',
'shell:buildBinary:linux:amd64',
'shell:downloadDockerBinary:linux:amd64',
'shell:buildBinary:linux:' + arch,
'shell:downloadDockerBinary:linux:' + arch,
'vendor:regular',
'html2js',
'useminPrepare:dev',
@ -53,7 +57,7 @@ module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
distdir: 'dist',
distdir: 'dist/public',
shippedDockerVersion: '17.09.0-ce',
pkg: grunt.file.readJSON('package.json'),
config: {
@ -68,8 +72,8 @@ module.exports = function (grunt) {
css: ['assets/css/app.css']
},
clean: {
all: ['<%= distdir %>/*'],
app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*', '!<%= distdir %>/docker*'],
all: ['<%= distdir %>/../*'],
app: ['<%= distdir %>/*', '!<%= distdir %>/../portainer*', '!<%= distdir %>/../docker*'],
tmpl: ['<%= distdir %>/templates'],
tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css']
},
@ -89,7 +93,8 @@ module.exports = function (grunt) {
release: {
src: '<%= src.html %>',
options: {
root: '<%= distdir %>'
root: '<%= distdir %>',
dest: '<%= distdir %>'
}
}
},
@ -183,7 +188,7 @@ module.exports = function (grunt) {
run: {
command: [
'docker rm -f portainer',
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app'
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics'
].join(';')
},
downloadDockerBinary: {

View File

@ -24,13 +24,13 @@
<!-- endbuild -->
<!-- Fav and touch icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/ico/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/ico/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/ico/favicon-16x16.png">
<link rel="manifest" href="/ico/manifest.json">
<link rel="mask-icon" href="/ico/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="/ico/favicon.ico">
<meta name="msapplication-config" content="/ico/browserconfig.xml">
<link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png">
<link rel="manifest" href="ico/manifest.json">
<link rel="mask-icon" href="ico/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="ico/favicon.ico">
<meta name="msapplication-config" content="ico/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
</head>

View File

@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "portainer",
"homepage": "http://portainer.io",
"version": "1.15.0",
"version": "1.15.1",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"

View File

@ -47,6 +47,7 @@ css:
- bower_components/angularjs-slider/dist/rzslider.css
- bower_components/codemirror/lib/codemirror.css
- bower_components/codemirror/addon/lint/lint.css
- bower_components/angular-json-tree/dist/angular-json-tree.css
minified:
- bower_components/bootstrap/dist/css/bootstrap.min.css
- bower_components/rdash-ui/dist/css/rdash.min.css
@ -58,6 +59,7 @@ css:
- bower_components/angularjs-slider/dist/rzslider.min.css
- bower_components/codemirror/lib/codemirror.css
- bower_components/codemirror/addon/lint/lint.css
- bower_components/angular-json-tree/dist/angular-json-tree.css
angular:
regular:
- bower_components/angular/angular.js
@ -74,6 +76,7 @@ angular:
- bower_components/ng-file-upload/ng-file-upload.js
- bower_components/angularjs-slider/dist/rzslider.js
- bower_components/angular-multi-select/isteven-multi-select.js
- bower_components/angular-json-tree/dist/angular-json-tree.js
minified:
- bower_components/angular/angular.min.js
- bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js
@ -89,3 +92,4 @@ angular:
- bower_components/ng-file-upload/ng-file-upload.min.js
- bower_components/angularjs-slider/dist/rzslider.min.js
- bower_components/angular-multi-select/isteven-multi-select.js
- bower_components/angular-json-tree/dist/angular-json-tree.min.js